69 Commits
Author SHA1 Message Date
daniel.subiabre 70ca4596a4 fix: update copy for "anonymous users" to "goteo.org users"
Prettier Format Check / format-check (pull_request) Canceled after 0s
2026-10-15 12:44:30 +02:00
daniel.subiabre 84ab232c3b fix: color of budget card 2026-10-15 12:24:12 +02:00
daniel.subiabre 8e38e3440f fix: change variant1 of tabs background to purple-soft 2026-10-15 12:20:36 +02:00
daniel.subiabre 3c469616ae fix: regressions introduced by open-core merge
Prettier Format Check / format-check (pull_request) Canceled after 0s
2026-10-08 17:01:58 +02:00
daniel.subiabre 619e63d07e Merge branch 'main' into feat/dtf-web 2026-10-08 16:51:15 +02:00
Daniel Subiabre García cc7ea665ec Merge pull request #3 from goteo/develop
chore: sync main to open-core develop
2026-10-08 16:48:26 +02:00
David Moreno CámaraandPep Villalonga b0e2db8908 [feat] Admit localized content for home hero (#503)
* fix: make home hero text required and media optional

* fix: hero start-only scheduling, prefilled form and history with delete

* feat: add hero preview modal to admin form and fix icon home

* fix: default home hero timestamps to current date

* feat: preview hero from history rows in a modal

* fix: accept local hero urls and reload after save

* feat: admit localized content for home hero, base language and translations

* feat: add itemClass to override DropdownItem classes

* chore: format code with prettier

* feat: allow hero translations in any language with a searchable picker

- Searchable picker fed by the full ISO 639-1 catalog, not limited to UI locales
- No base language: the platform language only seeds the form and can be
  removed once a second language exists
- Locale pattern validation in createHomeHero
- LanguagesDropdown controlled from the parent
- Chosen label shown in the picker input
- es.json hero keys

* fix: bind all 11 home_hero columns when storing a hero

The previous merge left 10 placeholders for 11 columns, so creating a hero row was failing.

* fix: drop unused TranslationRecord interface

* chore: format code with prettier

* feat: add updateHomeHero to overwrite a stored hero

* feat: preview heroes in any of their languages

* feat: validate title and content in every selected language

* feat: overwrite a hero from the history

* fix: rename admin home hero tabs

* chore: format code with Prettier

---------

Co-authored-by: Pep Villalonga <pep.villalonga@platoniq.net>
2026-10-08 16:25:30 +02:00
David Moreno Cámaraanddaniel.subiabre 208fff4e85 [feat] Add manage personal profile + organize me sections + add categories preferences selections + other (#519)
* feat: add shared profile manage components and utilities

* feat: add me section navigation and route restructuring

* feat: add personal/public profile forms with validation and actions

* chore: format code with prettier

* refactor: consolidate public profile action

* fix: remove public data from personal profile form

* fix: split public / private info forms

* fix: stale user handle, non-floating toasts

* feat: improve preferred language

* fix: remove non-necessary "Goteo" mentions

* fix: code style issues by eslint and prettier

---------

Co-authored-by: Daniel Subiabre <daniel.subiabre@platoniq.net>
2026-10-08 16:25:03 +02:00
Pep Villalonga Amorós 587dde7b9c fix: refresh session user roles from the API on each request (#525)
* fix: refresh session user roles from the API on each request

* refactor: resolve session in firewall with configurable cached-session paths
2026-10-08 16:24:40 +02:00
Pep Villalonga Amorós 02281f59d1 feat: unify project card with segmented progress bar (#526) 2026-10-08 16:22:55 +02:00
David Moreno Cámara 8c021b5806 [feat] Project reviews (areas + chat admin-user) (#523)
* chore: move RiskCard, ChatMessage and ChatTextarea components to /components/review

* feat: add review domain types, mocks and services

* feat: expose review actions and open reviews routes

* feat: build review areas and chat screens

* fix: navigate from BackButton links

* chore: remove review temporary CTA from home

* chore: format code by prettier
2026-10-07 13:44:39 +02:00
Pep Villalonga Amorós 935f183f76 fix: show owned projects in profile promoted tab and move donated ones to donor type (#520)
* fix: show owned projects in profile promoted tab and move donated ones to donor type

* fix: prefer project cover and build owner IRI from operation path
2026-10-07 13:40:34 +02:00
Pep Villalonga Amorós 888a954f68 feat: add project review admin page (#517)
* feat: add project review admin page

* fix: scope project review to campaign statuses and trust API currency
2026-10-07 13:37:23 +02:00
6eeb6f06dd use global.css color variables in ProgressChart canvas (#514)
Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>
Co-authored-by: Daniel Subiabre <daniel.subiabre@platoniq.net>
2026-10-05 17:32:48 +02:00
Pep Villalonga Amorós 793683d5bb chore: remove Platoniq deploy infra for open core (#512)
* chore: remove Platoniq deploy infra for open core

* feat: make share widget host configurable via PUBLIC_WIDGET_URL

* fix: build widget link from current host

* docs: update README and drop deploy pipeline references
2026-10-05 17:31:35 +02:00
Pep Villalonga Amorós 190f5275e6 feat: add project manage page for owners and admins (#504)
* feat: add project manage page for owners and admins

* fix: widen claim status columns and add bottom padding

* feat: add user search and CSV export to manage tables

* fix: address review on project manage page

* feat: show non-anchored toasts floating at bottom-right
2026-10-05 17:31:13 +02:00
Pep Villalonga Amorós 18255969e0 fix: show project cover on project page and link owner profile (#518) 2026-10-05 17:24:11 +02:00
Pep Villalonga Amorós 71991431ff feat: show non-anchored toasts floating at bottom-right (#515) 2026-10-05 17:14:50 +02:00
daniel.subiabre 05e536899b fix: code style issues by eslint and prettier 2026-10-05 15:59:34 +02:00
daniel.subiabre 34cadfbb26 feat: add boldonse for h1, h2, h3 2026-10-05 15:57:01 +02:00
daniel.subiabre d2345578cc feat: DTF logo in footer 2026-10-05 15:51:56 +02:00
daniel.subiabre 69bcc612e7 feat: update Footer logos 2026-10-05 10:59:29 +02:00
daniel.subiabre c7d31a1f76 feat: update shelves look 2026-10-02 16:02:56 +02:00
daniel.subiabre aeded636dc feat: reduce vspacing between home blocks 2026-10-02 16:02:44 +02:00
daniel.subiabre affc8af8d0 fix: code style fixes by eslint and prettier
Prettier Format Check / format-check (pull_request) Canceled after 0s
2026-10-02 14:12:55 +02:00
daniel.subiabre 4b341047c6 feat: add about DTF static content 2026-10-02 14:10:47 +02:00
daniel.subiabre f91c8403d6 feat: update blog card 2026-10-02 14:03:26 +02:00
daniel.subiabre 557ff564e2 feat: update shelves prototype cards 2026-10-02 13:57:44 +02:00
daniel.subiabre fb9a613614 feat: update Matchfunding top card 2026-10-02 13:28:07 +02:00
daniel.subiabre 8ab1f96c3c fix: code style issues by eslint and prettier 2026-10-02 12:12:30 +02:00
daniel.subiabre d326eec947 feat: update footer contents 2026-10-02 12:08:54 +02:00
daniel.subiabre 3294f0d8ea fix: wrong projects and bad colors in campaigns carrousel 2026-10-02 11:52:23 +02:00
daniel.subiabre ab703eaed1 feat: update Matchfunding section to mockup 2026-10-02 11:49:56 +02:00
daniel.subiabre c4d1bd4c6e fix: store changes seen during review 2026-10-02 11:31:07 +02:00
daniel.subiabre 33d4866bdf fix: update borders and round radii 2026-10-02 09:38:49 +02:00
daniel.subiabre 1ea02dce87 feat: update campaign card to DTF style 2026-10-02 03:21:03 +02:00
daniel.subiabre dd1c5ed713 feat: update header layout 2026-10-02 03:14:50 +02:00
daniel.subiabre 40e05989bb feat: update footer data and assets 2026-10-02 03:05:08 +02:00
daniel.subiabre 2047e58ed6 feat: update Logo 2026-10-02 02:53:32 +02:00
daniel.subiabre 24d121a56a feat: update selected campaings in CampaignsTabs 2026-10-02 02:30:36 +02:00
daniel.subiabre 928595785d feat: pilot update of call cards data 2026-10-02 02:24:53 +02:00
daniel.subiabre 1e168faa08 feat: update order of home sections 2026-10-02 02:24:31 +02:00
daniel.subiabre 3e709f8c98 Merge branch 'main' into feat/dtf-web 2026-10-02 02:16:48 +02:00
Daniel Subiabre García 2fd9d99bf7 Merge pull request #516 from goteo/hotfix/missing-project-community-items-on-all-anon (#2)
fix: show grid with all elements regardless of group length
2026-10-02 02:16:33 +02:00
daniel.subiabre d6db88be3b Merge pull request #516 from goteo/hotfix/missing-project-community-items-on-all-anon
fix: show grid with all elements regardless of group length
2026-10-01 20:38:03 +02:00
daniel.subiabre 4c4be4d86e Merge branch 'main' into feat/dtf-web
Prettier Format Check / format-check (pull_request) Canceled after 0s
2026-09-29 16:37:52 +02:00
Daniel Subiabre García 45b71d8be2 Merge branch 'goteo:main' into main 2026-09-29 16:36:10 +02:00
8fba3034d4 chore: sync main to develop (#513)
* refactor: extract shared debounce util

* fix: project search in admin highlights

* fix: add save feedback, cover images and 84px cells to highlights

* fix: show selection and empty state in basic dropdown

* docs: update SKILL-template to current codebase (#507)

* docs: update SKILL-template to current codebase

* fix: remove unnecesary check command alias

* fix: update SDK regen command from `openapi` to `sdk`

* fix: indentation on command table

---------

Co-authored-by: Daniel Subiabre <daniel.subiabre@platoniq.net>

* feat: Improve footer config (#508)

* feat: hide footer funding section if empty

* feat: update footer social link type

* fix: move footer data out of ad-hoc lone config folder

* fix: code style issues by eslint and prettier

* chore: import zod from zod package in actions (#506)

* feat: show user avatar in header (#505)

* Feat/implement reward claims (#471)

* create RewardClaims after payment

* replace ClearCart with RewardClaims

* mount RewardClaim on payment verification

* format

* match charges by fields and order to avoid duplicate claims

* guard RewardClaims whit allSucceeded, soft-delete carts via finalised flag

* format

* feat: add POST /api/checkout/rewardClaims endpoint

* refactor: make RewardClaims.svelte a  thin client that submit the cart

* fix: pass checkoutId into the verified-checkout reward claims flow

* fix: format

---------

Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>

* Feat/refactor activity section (#480)

* refactor ActivitySection (DonationsCard-MatchfundingCard-ProjectsCart)

* format

* refactor: apply DonationsCard (ld+json totals, batch slug[], money_total)

* refactor: ProjectsCard (ld-json totals, async, remove lang)

* refactor: MatchfundingCard (drop lang prop)

---------

Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>

* feat: implement promoter public profile form in /me/manage (#495)

* feat: implement promoter public profile form in /me/manage

* feat: add view profile button to /me/manage

* feat: persist social links and fix profile PATCH

* fix: load current user in /me/manage so social links show up

* fix: use design social icons on user profile

* feat: accept username or link in profile social fields

* fix: always send links on profile update so they are not cleared

* Fix: admin highlights save (#511)

* fix: cast highlight slot project ids to integer

* fix: drop highlight slots outside selected layout on save

* feat: add link, lists and quote to RichTextEditor with opt-out toolbar props (#509)

* feat: add link, lists and quote to RichTextEditor with opt-out toolbar props

* feat: add heading select to RichTextEditor and close link popover on outside click

* refactor: simplify search page state (#510)

* refactor: simplify search page state

* fix: update naming convention examples

---------

Co-authored-by: Daniel Subiabre <daniel.subiabre@platoniq.net>

* Feat/user preferences manage screen (#496)

* Render ManagePreferencesSection in /me/manage

* add ManagePreferencesSection component

* add manage preferences i18n key to es.json

* add manage preferences i18n keys

* add ManagePreferencesSection with form, language cookies and PATCH

* add dedicated /me/menege/preferences route

* link preferences screen from /me/manage sidebar

* fix: remove pointless user PATCH, adopt twMerge

---------

Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>

---------

Co-authored-by: Pep Villalonga <pep.villalonga@platoniq.net>
Co-authored-by: angeldipinto <72363089+angeldipinto@users.noreply.github.com>
Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>
2026-09-29 16:35:50 +02:00
angeldipintoandAngel Di Pinto 3141241b2d Feat/user preferences manage screen (#496)
* Render ManagePreferencesSection in /me/manage

* add ManagePreferencesSection component

* add manage preferences i18n key to es.json

* add manage preferences i18n keys

* add ManagePreferencesSection with form, language cookies and PATCH

* add dedicated /me/menege/preferences route

* link preferences screen from /me/manage sidebar

* fix: remove pointless user PATCH, adopt twMerge

---------

Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>
2026-09-29 13:43:57 +02:00
Pep Villalonga Amorósanddaniel.subiabre ddafe0dc7d refactor: simplify search page state (#510)
* refactor: simplify search page state

* fix: update naming convention examples

---------

Co-authored-by: Daniel Subiabre <daniel.subiabre@platoniq.net>
2026-09-29 13:30:42 +02:00
Pep Villalonga Amorós 6ccb2baff2 feat: add link, lists and quote to RichTextEditor with opt-out toolbar props (#509)
* feat: add link, lists and quote to RichTextEditor with opt-out toolbar props

* feat: add heading select to RichTextEditor and close link popover on outside click
2026-09-29 13:23:19 +02:00
Pep Villalonga Amorós 83de5412a0 Fix: admin highlights save (#511)
* fix: cast highlight slot project ids to integer

* fix: drop highlight slots outside selected layout on save
2026-09-28 17:01:05 +02:00
Pep Villalonga Amorós 21f06bba3d feat: implement promoter public profile form in /me/manage (#495)
* feat: implement promoter public profile form in /me/manage

* feat: add view profile button to /me/manage

* feat: persist social links and fix profile PATCH

* fix: load current user in /me/manage so social links show up

* fix: use design social icons on user profile

* feat: accept username or link in profile social fields

* fix: always send links on profile update so they are not cleared
2026-09-28 15:18:08 +02:00
angeldipintoandAngel Di Pinto 5881b75645 Feat/refactor activity section (#480)
* refactor ActivitySection (DonationsCard-MatchfundingCard-ProjectsCart)

* format

* refactor: apply DonationsCard (ld+json totals, batch slug[], money_total)

* refactor: ProjectsCard (ld-json totals, async, remove lang)

* refactor: MatchfundingCard (drop lang prop)

---------

Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>
2026-09-28 15:17:41 +02:00
angeldipintoandAngel Di Pinto 71c4cd050c Feat/implement reward claims (#471)
* create RewardClaims after payment

* replace ClearCart with RewardClaims

* mount RewardClaim on payment verification

* format

* match charges by fields and order to avoid duplicate claims

* guard RewardClaims whit allSucceeded, soft-delete carts via finalised flag

* format

* feat: add POST /api/checkout/rewardClaims endpoint

* refactor: make RewardClaims.svelte a  thin client that submit the cart

* fix: pass checkoutId into the verified-checkout reward claims flow

* fix: format

---------

Co-authored-by: Angel Di Pinto <angel.dipinto@platoniq.net>
2026-09-28 15:17:11 +02:00
Pep Villalonga Amorós aae796d3d1 feat: show user avatar in header (#505) 2026-09-28 15:16:51 +02:00
Pep Villalonga Amorós 4e558cf115 chore: import zod from zod package in actions (#506) 2026-09-25 15:38:22 +02:00
daniel.subiabre 966ffd7d07 feat: Improve footer config (#508)
* feat: hide footer funding section if empty

* feat: update footer social link type

* fix: move footer data out of ad-hoc lone config folder

* fix: code style issues by eslint and prettier
2026-09-25 15:35:13 +02:00
Pep Villalonga Amorósanddaniel.subiabre 0f583d0fe1 docs: update SKILL-template to current codebase (#507)
* docs: update SKILL-template to current codebase

* fix: remove unnecesary check command alias

* fix: update SDK regen command from `openapi` to `sdk`

* fix: indentation on command table

---------

Co-authored-by: Daniel Subiabre <daniel.subiabre@platoniq.net>
2026-09-25 15:34:49 +02:00
daniel.subiabre 9ea6139c27 Merge branch 'main' into feat/dtf-web 2026-09-25 12:24:51 +02:00
Daniel Subiabre GarcíaandPep Villalonga ae8ed6aacb chore: sync main to core develop (#1)
* refactor: extract shared debounce util

* fix: project search in admin highlights

* fix: add save feedback, cover images and 84px cells to highlights

* fix: show selection and empty state in basic dropdown

---------

Co-authored-by: Pep Villalonga <pep.villalonga@platoniq.net>
2026-09-25 12:23:50 +02:00
daniel.subiabre 8f1fbf2768 Merge pull request #497 from goteo/fix/admin-highlights-project-search
Fix: admin highlights project search
2026-09-25 12:20:11 +02:00
Pep Villalonga 08e98ca82a fix: show selection and empty state in basic dropdown 2026-09-25 12:13:24 +02:00
daniel.subiabre 556c171ce9 fix: update usage of colors 2026-09-25 11:55:16 +02:00
daniel.subiabre c802acae6f feat: update colour palette
Prettier Format Check / format-check (pull_request) Canceled after 0s
2026-09-24 14:58:03 +02:00
daniel.subiabre f3271dc113 feat: update app config names to DTF 2026-09-24 14:51:57 +02:00
Pep Villalonga cb89e4bed1 fix: add save feedback, cover images and 84px cells to highlights 2026-09-24 10:41:14 +02:00
Pep Villalonga 9382f69585 fix: project search in admin highlights 2026-09-23 13:04:59 +02:00
Pep Villalonga 292936a218 refactor: extract shared debounce util 2026-09-23 13:04:59 +02:00
205 changed files with 9490 additions and 4689 deletions
-39
View File
@@ -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
+67 -50
View File
@@ -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 |
+128 -625
View File
@@ -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 `<style>` blocks and CSS modules
Defined in `src/styles/global.css` via Tailwind `@theme`. Always use token classes — never hardcode hex values.
**Colors** (hex values in `src/styles/global.css`):
| Token class | Use |
| ------------------------------------------------ | ----------------------------- |
| `bg-primary` / `text-primary` | Primary brand (teal) |
@@ -102,72 +70,23 @@ Defined in `src/styles/global.css` via Tailwind `@theme`. Always use token class
| `bg-variant1..4` | Surface variants |
| `bg-semantic-error/success/notification/warning` | Semantic states |
Dark theme tokens apply when `[data-theme="dark"]` is set on a parent element.
**Typography:**
| Token | Value |
| ----------------- | ------------------------------- |
| `font-body` | Karla (Google Font), sans-serif |
| `text-body-small` | `0.875rem` |
| `text-double` | `2rem` |
Body default: `font-body`, `font-weight: 400`, `line-height: 140%`.
**Layout:**
| Class | Behaviour |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `.wrapper` | Max-width 1440px, centered, horizontal padding `10 * --spacing` (responsive: 1rem at ≤768px, 0.5rem at ≤425px) |
| `.no-scroll` | `overflow: hidden` |
Dark theme tokens apply when `[data-theme="dark"]` is set on a parent element. Layout container: `.wrapper` (max-width 1440px, responsive padding).
### i18n
All user-facing strings via `t()` from `src/i18n/store`. Never hardcode strings visible to users.
All user-facing strings via `t()` — never hardcode visible strings. Locales: `es` (default), `en`, `ca`.
**Supported locales:** `es` (default), `en`, `ca`. Keys live in `src/i18n/locales/{es,en,ca}.json` as nested objects.
- Svelte: `import { t } from "../i18n/store"` → `{$t("header.search")}`
- Astro / actions: `const { t } = Astro.locals` / `context.locals.t`
- Interpolation: `{{ varName }}` in JSON → `$t("key", { varName: 1 })`
- HTML keys: `<Thtml key="footer.legal" vars={{ year: 2025 }} />` (`library/typography/Thtml.svelte`)
**In Svelte components** — `t` is a Svelte store; subscribe with `$t`:
**`$t` on the server always renders the default locale (es).** The locale store is only set client-side in `App.svelte`, so a Svelte component rendered without a `client:*` directive shows Spanish on `/en` and `/ca`. If it uses `$t`, keep a `client:*` directive or pass translated strings as props from the `.astro` parent via `Astro.locals.t`.
```svelte
<script lang="ts">
import { t } from "../i18n/store";
</script>
<p>{$t("header.search")}</p>
```
**In Astro pages** — `t` is injected as a plain function via `Astro.locals`:
```astro
---
const { t } = Astro.locals;
---
<title>{t("me.page.title")}</title>
```
**Interpolation** — use `{{ varName }}` placeholders in JSON, pass values as second arg:
```typescript
// locales/es.json: { "project.funded": "Financiado al {{ percent }}%" }
$t("project.funded", { percent: 85 }); // → "Financiado al 85%"
```
**HTML content** — escaped by default. Pass `{ allowHTML: true }` to render HTML tags:
```typescript
$t("footer.legal", {}, { allowHTML: true });
```
Missing keys log a console warning and fall back to the key string.
**Add new keys only to `src/i18n/locales/es.json`. Never modify `en.json` or `ca.json` manually — those translations are managed by Crowdin and manual edits will be overwritten.**
**Add new keys only to `src/i18n/locales/es.json`. Never modify `en.json` or `ca.json` — they are managed by Crowdin and manual edits will be overwritten.**
### Error handling
Use `AuthErrorType` enum and `createAuthError()` from `src/openapi/api.ts`. Map to user-facing messages with `getErrorTranslationKey(error.type)` + `t()`:
```typescript
import { createAuthError, getErrorTranslationKey } from "../openapi/api";
@@ -179,180 +98,140 @@ try { ... } catch (err) {
### Formatting
- Prettier: 4-space indent, 100-char line width, Astro + Svelte + TailwindCSS plugins
- ESLint flat config (`eslint.config.js`): TypeScript + Svelte + Cypress rules
Prettier: 4-space indent, 100-char line width. ESLint flat config (`eslint.config.js`).
## Component library
Before building new UI, check `src/components/library/` and `src/components/icons/`. All components accept a `class` prop merged via `tailwind-merge` for safe overrides.
Before building new UI, check `src/components/library/` and `src/components/icons/` (icons use `currentColor`; `Chevron` takes `direction`). Import via `library/<category>/<Component>.svelte`.
### `src/components/library/`
| Subfolder | Components |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `buttons/` | `Button`, `ActionableButton`, `BackButton`, `ReturnButton`, `ReturnHeader`, `PaginationNavButton` |
| `cards/` | `Card`, `BaseCard`, `Reward` |
| `dropdown/` | `DropdownMenu`, `DropdownItem` |
| `feedback/` | `Toast`, `Loader`, `DeleteModal` |
| `filters/` | `FilterComposer`, `FilterComposerRow`, `TerritoryReferentInput` |
| `inputs/` | `TextInput`, `PasswordInput`, `TextArea`, `Select`, `DateInput`, `Checkbox`, `RadioButton`, `RangeSlider`, `Toggle`, `ToggleSwitch`, `CategorySelect`, `Search`, `Email`, `FileUpload`, `ImageUploadModal`, `CurrencyInput`, `RichTextEditor`, `TerritoryInput` |
| `layout/` | `Grid`, `AccordionBox`, `CollapsibleBox`, `Carousel`, `Tabs`, `TabNavigation` |
| `paginations/` | `Pagination` |
| `share/` | `ShareButton`, `CopyUrl`, `Facebook`, `X`, `Iframe` |
| `tables/` | `DataTable` |
| `tags/` | `Tag`, `Category`, `AccountingOwnerBadge` |
| `theme/` | `ThemeToggle.astro` |
| `typography/` | `BodyText`, `BodyBlog`, `Title`, `Thtml` |
Components are organized in category subfolders — import via `library/<category>/<Component>.svelte`:
Text inputs share `labelText`, `helperText` and `error?: string` — `error` is the message, not a boolean.
| Subfolder | Components |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `buttons/` | `Button`, `ActionableButton`, `BackButton`, `ReturnButton`, `ReturnHeader` |
| `cards/` | `Card`, `BaseCard` |
| `dropdown/` | `DropdownMenu`, `DropdownItem` (+ `dropdown.types.ts`) |
| `feedback/` | `Toast`, `Loader` |
| `inputs/` | `TextInput`, `PasswordInput`, `TextArea`, `Select`, `DateInput`, `Checkbox`, `RadioButton`, `RangeSlider`, `Toggle`, `ToggleSwitch`, `CategorySelect`, `Search`, `Email`, `FileUpload` |
| `layout/` | `Grid`, `AccordionBox`, `CollapsibleBox`, `Carousel`, `Tabs`, `TabNavigation` |
| `share/` | `ShareButton`, `CopyUrl`, `Facebook`, `X`, `Iframe` |
| `tags/` | `Tag`, `Category` |
| `theme/` | `ThemeToggle.astro` |
| `typography/` | `BodyText`, `BodyBlog`, `Thtml` |
### `twMerge` for new components
Key props of the most used ones:
| Component | Key props | Notes |
| ------------------ | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `Button` | `size?: "md"\|"sm"`, `kind?: "primary"\|"secondary"\|"ghost"\|"invert"`, `children` | Spreads all `HTMLButtonAttributes` |
| `ActionableButton` | (see file) | Button with async action + loading state |
| `TextInput` | `bind:value`, `type?`, `labelText?`, `helperText?`, `error?`, `required?`, `disabled?` | Floating label, auto-generated `id` |
| `PasswordInput` | `bind:value`, `labelText?`, `error?` | TextInput with show/hide toggle |
| `TextArea` | `id`, `bind:value`, `label?`, `helper?`, `error?: boolean`, `rows?=4`, `disabled?` | `error` is a boolean; use `helper` for the error message |
| `Select` | `bind:value`, `labelText?`, `helperText?`, `error?`, `onChange?`, `onBlur?`, `children` (options) | Accessible floating label select |
| `Tag` | `variant?: "success"\|"warning"\|"error"\|"bold"`, `children` | Small badge pill |
| `Toast` | `bind:showToast`, `variant: "error"\|"success"\|"notification"\|"warning"`, `children`, `button?`, `link?` | Self-dismisses on close |
| `Card` | `children`, `class?` | White rounded card with shadow |
| `Grid` | `children`, `class?` | 2-col mobile → 3-col desktop grid |
| `Toggle` | `onChange?: (value: boolean) => void` | Animated toggle, `$bindable` internal state |
Usage example:
```svelte
import Button from "../library/buttons/Button.svelte"; import TextInput from
"../library/inputs/TextInput.svelte"; import Toast from "../library/feedback/Toast.svelte"; let name
= $state(""); let showError = $state(false);
```
```svelte
<TextInput bind:value={name} labelText="Name" error={nameError} />
<Button kind="primary" onclick={submit}>Save</Button>
<Toast bind:showToast={showError} variant="error">Something went wrong.</Toast>
```
### `src/components/icons/`
Icons are Svelte components accepting `width?`, `height?`, and `class?`. They use `currentColor` — set color via `text-*` class on the icon or a parent. Most icons live in category subfolders — import via `icons/<category>/<Icon>.svelte`:
```svelte
import Chevron from "../icons/navigation/Chevron.svelte"; import Close from
"../icons/navigation/Close.svelte"; import Warning from "../icons/status/Warning.svelte";
```
```svelte
<Chevron direction="down" width="20" height="20" class="text-secondary" />
<Close class="size-5" />
```
`Chevron` accepts `direction?: "left"|"right"|"up"|"down"`.
| Subfolder | Icons |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions/` | `Bookmark`, `Check`, `Copy`, `Download`, `Edit`, `MinusIcon`, `PlusIcon`, `RememberIcon`, `Search`, `Send`, `Share`, `Trash`, `UploadFile`, `UploadIcon` |
| `commerce/` | `Bag`, `Basket`, `BudgetMark`, `CreditCard`, `Money`, `Wallet` |
| `filters/` | `FilterIcon`, `Filters`, `MoreAndLess`, `ShortBy` |
| `media/` | `Code`, `Eye`, `Flash`, `Image`, `Link`, `Play`, `VideoIcon`, `Web` |
| `navigation/` | `Arrow`, `ArrowSliderIcon`, `Back`, `Chevron`, `Close`, `CloseMenu`, `Forward`, `Hamburger`, `Home`, `Menu` |
| `payment/` | `CreditCardIcon.astro`, `PaypalIcon.astro`, `StripeIcon.astro` |
| `social/` | `Facebook`, `Gmail`, `Instagram`, `Linkedin`, `MediumIcon`, `Web`, `X` (+ `*Icon` variants, some `.astro`) |
| `status/` | `AlertIcon`, `Error`, `Flames`, `NotificationIcon`, `Ok`, `Spinner`, `SuccessIcon`, `Warning` |
| `user/` | `Profile`, `User` |
| (root) | `Align`, `Box`, `Bullet`, `Calendar`, `Category`, `Clock`, `Comments`, `DefaultAvatar`, `Goteo`, `Infinity`, `LanguageIcon`, `Languages`, `LineIcon`, `Location`, `Logo`, `Mail`, `PaginationFirst`, `PaginationLast`, `UnitIcon` |
## Svelte 5 runes
This project uses **Svelte 5 runes** syntax. Do not use Svelte 4 patterns (`export let`, `$:`, `onMount`, writable stores for component state).
| Svelte 4 (wrong) | Svelte 5 runes (correct) |
| ---------------------- | ------------------------------- |
| `export let prop` | `let { prop } = $props()` |
| `let x = 0` (reactive) | `let x = $state(0)` |
| `$: derived = a + b` | `let derived = $derived(a + b)` |
| `onMount(() => ...)` | `$effect(() => ...)` |
| `<slot />` | `{@render children()}` |
| `<slot name="x" />` | `{@render x()}` |
Props with types:
All library components accept a `class` prop merged with `tailwind-merge`. Replicate it:
```svelte
<script lang="ts">
import type { Snippet } from "svelte";
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
label: string;
children: Snippet;
onChange?: (value: boolean) => void;
class?: ClassNameValue;
}
let { label, children, onChange }: Props = $props();
let active = $state(false);
let display = $derived(active ? "on" : "off");
let { class: classes = "", ...rest }: Props = $props();
</script>
<div class={twMerge("base-classes-here", classes)}>...</div>
```
Svelte stores (like `$t` from i18n) are still subscribed with `$storeName` syntax in the template — this is not Svelte 4 reactive syntax, it's the store contract.
## Svelte 5 runes
## Astro pages vs Svelte components
Do not use Svelte 4 patterns.
Use `.astro` for server-rendered pages and layouts (no client-side reactivity needed). Use `.svelte` for interactive components.
| Svelte 4 (wrong) | Svelte 5 runes (correct) |
| --------------------------- | ----------------------------------------------- |
| `export let prop` | `let { prop } = $props()` |
| `let x = 0` (reactive) | `let x = $state(0)` |
| `$: derived = a + b` | `let derived = $derived(a + b)` |
| `$: { sideEffect() }` | `$effect(() => { sideEffect() })` |
| `on:click={fn}` | `onclick={fn}` |
| `on:submit\|preventDefault` | `onsubmit={(e) => { e.preventDefault(); ... }}` |
| `<slot />` | `{@render children()}` |
| `<slot name="x" />` | `{@render x()}` |
To hydrate a Svelte component client-side, use a `client:*` directive:
`onMount` is still valid for run-once setup; prefer `$effect` when the code depends on reactive state. Stores (like `$t`) are still subscribed with `$storeName`.
```astro
<MyComponent client:load />
<!-- hydrate immediately -->
<MyComponent client:idle />
<!-- hydrate when browser is idle -->
<MyComponent client:visible />
<!-- hydrate when enters viewport -->
```
## Pages, `App.svelte` and session
Without a `client:*` directive, a Svelte component renders as static HTML only (no JS sent to browser).
`Astro.locals` (set by `src/middleware/index.ts`): `lang`, `langs` (Accept-Language prefs), `t`, `session` (`undefined` if logged out). Same fields in actions via `context.locals`.
`Astro.locals` is injected by middleware and contains `lang`, `t`, and `session` (if authenticated):
**Always wrap interactive page content with `<App client:load>`** (`src/layouts/App.svelte`). It renders `<Header>` and `<Footer>` — do not add them manually — and attaches the OpenAPI client interceptors (auth headers + browser cache).
```astro
---
const { lang, t, session } = Astro.locals;
if (!session) return goto("/login", { query: { callback: "/me" } });
---
<Layout title={t("page.title")}>
<App client:load locale={lang} session={session}>...</App>
</Layout>
```
## Authentication in Astro pages
`session` is optional on `<App>`: without it, `App` fetches it from `/api/session` after hydration. **Pages edge-cached via `cacheableRoutes` in `astro.config.mjs` (home, about, project, user, static) are served to every visitor, so they must not pass `session` or render any per-visitor content.** On other pages, pass it as a prop rather than re-fetching. `session.token.asHttpHeaders` is a ready-made `{ Authorization: "Bearer ..." }` for server-side API calls.
Session comes from `Astro.locals.session` (set by middleware via `src/auth/session.ts`). Guard protected pages:
Route access by role is declared in `ACL` in `src/firewall/access-control.ts` (path regex → roles, e.g. `/admin` → `ROLE_ADMIN`). A new route that needs login or a role must be added there.
```astro
---
import { goto } from "../../utils/navigation";
`src/pages/api/relay/[...path].ts` is a server-side auth proxy for edge cases needing raw `fetch` with auth.
const { session } = Astro.locals;
## Zod validation
if (!session) {
return goto("/login", { query: { callback: "/me" } });
}
Import Zod from the `zod` package, not from `astro/zod`. Both resolve to the same installed copy, so the choice is about who owns the version: `zod` is a declared dependency of this project, while `astro/zod` follows whatever Astro depends on internally — it moved from v3 to v4 in an Astro upgrade with no signal.
const user = session.user;
const accounting = session.accounting;
---
```
Keep the `zod` range aligned with Astro's when bumping majors, so pnpm keeps deduplicating to one copy instead of bundling two.
Pass session down to Svelte components as a prop — do not re-fetch it client-side.
Register new actions in the `server` object of `src/actions/index.ts`. Throw `ActionError` with a translated message for user-facing errors.
**`Session` type** (`src/auth/types.ts`):
## SDK (`@hey-api`)
Generated in `src/openapi/client/` — do not edit. Regenerate with `pnpm sdk` (needs the API running and env vars set); commit generated files together with the config change.
Functions follow `api{Resource}{Operation}` (e.g. `apiProjectsIdOrSlugGet`, `apiUsersIdpersonPatch`); path constants add a `Url` suffix in `operation-paths.gen.ts`.
The browser cache interceptor is only active when `PUBLIC_CSCACHE="true"`, caches anonymous GETs only, and is purged when a session ends. To force a fresh read (e.g. right after a mutation):
```typescript
interface Session {
token: OAuthToken; // access_token, refresh_token, asHttpHeaders
user: User; // OpenAPI-generated User type
accounting: Accounting; // OpenAPI-generated Accounting type
expires_at: Date; // Auto-refreshed by middleware 5 min before expiry
}
import { withoutCache } from "../openapi/cacheInterceptor";
const project = await withoutCache(() => apiProjectsIdOrSlugGet({ path: { idOrSlug } }));
```
`session.token.asHttpHeaders` is a ready-made `{ Authorization: "Bearer ..." }` object for API calls.
## Key utilities
Reuse these instead of reimplementing:
| Util | Import | Use |
| ---------------------------------- | ----------------------------- | ------------------------------------------------------------------- |
| `goto(target, options?)` | `src/utils/navigation.ts` | Server-side redirect from Astro pages; returns a `Response` |
| `toCollectionItems<T>(collection)` | `src/utils/hydra.ts` | Items of a Hydra collection response — don't read `.member` by hand |
| `extractId(iri)` | `src/utils/extractId.ts` | `"/v4/users/42"` → `"42"` |
| `formatCurrency(money, options?)` | `src/utils/currencies.ts` | Locale-aware formatting of a `Money` object |
| `sumMoney` / `multiplyMoney` | `src/utils/money.ts` | `Money` arithmetic |
| `renderMarkdown(text)` | `src/utils/renderMarkdown.ts` | Markdown → HTML, links open in new tab |
| `getBaseUrl()` / `getEnvVar(key)` | `src/utils/consts.ts` | Env var access, throws if missing |
## Image upload
Never call S3 from client code. Use `uploadImage(file, { onProgress })` from `src/utils/media/imageUpload.ts` (or `createImageUploader()` from `imageUpload.svelte.ts` for reactive `$state`). Flow: `/api/upload/preupload` (signed URL) → PUT to S3 → `/api/upload/postupload` (validate, hash, move to public path).
## Environment variables
Full list in `.env.example`. Non-obvious ones:
- `CLOUDFLARE_INCLUDE_PROCESS_ENV="true"` — required for Cloudflare Workers to read env vars.
- `PUBLIC_CSCACHE="true"` — enables the browser cache interceptor.
## Cloudflare Workers constraints
Runtime code (pages, components, services) must not use Node.js-only APIs (`fs`, `path`, `os`, `child_process`, etc.). Use Web APIs (`fetch`, `crypto`, `URL`, `Cache`) instead. Build-time code (config files, scripts, plugins) may use Node.js APIs freely.
A Content Security Policy is set in `astro.config.mjs` (`security.csp`, applies to build/preview only, not `astro dev`). A new external host for `fetch`, scripts or iframes must be added there or the browser blocks it.
## Branch naming
@@ -363,396 +242,20 @@ interface Session {
| `hotfix/` | Urgent production fix |
| `chore/` | Maintenance, deps, config, tooling — no production behaviour change |
Example: `feat/add-project-filters`, `fix/checkout-redirect`, `chore/update-openapi-sdk`.
## `Astro.locals`
Injected by `src/middleware/index.ts` on every request. Available in Astro pages and server actions via `context.locals`:
| Field | Type | Description |
| --------- | ------------------------------- | ------------------------------------------------------------------------ |
| `lang` | `Locale` (`"es"\|"en"\|"ca"`) | Current interface language, derived from user preferences or app default |
| `langs` | `string[]` | Full list of detected user preferred languages |
| `t` | `(key, vars?, opts?) => string` | Translation function for current locale |
| `session` | `Session \| undefined` | Authenticated session, or `undefined` if not logged in |
```astro
---
const { lang, t, session } = Astro.locals;
---
```
In server actions (`src/actions/`): `context.locals.t` and `context.locals.session`.
## `App.svelte` — page shell
`src/layouts/App.svelte` is the interactive page shell. It renders `<Header>`, `<main>`, and `<Footer>` (also in `src/layouts/`), initializes the locale/session Svelte stores, and attaches the browser cache interceptor for the OpenAPI client.
**Always wrap interactive page content with `<App client:load>`:**
```astro
---
import App from "../../layouts/App.svelte";
const { lang, session } = Astro.locals;
---
<Layout title={t("page.title")}>
<App client:load locale={lang} session={session}>
<!-- page content here -->
</App>
</Layout>
```
Props: `locale: Locale`, `session?: Session`, `class?: ClassNameValue`.
Do not add `<Header>` or `<Footer>` manually inside pages — `App` renders them.
## `twMerge` for new components
All library components use `tailwind-merge` for class composition. Replicate the same pattern in new components:
```svelte
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
class?: ClassNameValue;
// ...other props
}
let { class: classes = "", ...rest }: Props = $props();
</script>
<div class={twMerge("base-classes-here", classes)}>...</div>
```
This allows callers to safely override or extend styles without class conflicts.
## Key utilities
| Util | Import | Use |
| ------------------------ | ------------------------- | --------------------------------------------------------------------------- |
| `goto(target, options?)` | `src/utils/navigation.ts` | Server-side redirect from Astro pages; returns a `Response` |
| `extractId(iri)` | `src/utils/extractId.ts` | Extracts numeric id from an API IRI string (e.g. `"/v4/users/42"` → `"42"`) |
| `getBaseUrl()` | `src/utils/consts.ts` | Returns `PUBLIC_API_URL` env var (throws if missing) |
| `getApiVersion()` | `src/utils/consts.ts` | Returns `PUBLIC_API_VERSION` env var |
| `getEnvVar(key)` | `src/utils/consts.ts` | Generic env var accessor with throw-on-missing |
## Zod validation
Zod is available via `astro/zod` (re-exported by Astro) — do not install a separate `zod` package.
Use it in `src/actions/` to validate action inputs:
```typescript
import { z } from "astro/zod";
import { defineAction, ActionError } from "astro:actions";
export const myAction = defineAction({
accept: "form",
input: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
handler: async (input, context) => {
const { t } = context.locals;
// input is fully typed and validated
},
});
```
Throw `ActionError` with a translated message for user-facing errors:
```typescript
throw new ActionError({
code: "BAD_REQUEST",
message: t("register.error.incompleteFields"),
});
```
## Before opening a PR
```bash
pnpm format # Run Prettier — fix formatting before committing
pnpm format # ESLint --fix + Prettier
pnpm check:astro # astro check — .astro files (+ .ts)
pnpm check:svelte # svelte-check — .svelte files (+ .ts)
```
`pnpm check` is Prettier only, not a type check. ESLint does not type-check. Neither type check passes on a clean tree, so they are not CI gates — grep the output for the files you touched. `check:types` runs both but stops at the first failure.
## Debugging
`astro dev` runs in `workerd`, and not all Worker logs reach the terminal. The inspector is pinned to port `9229` (`inspectorPort` in `astro.config.mjs`); attach with VS Code **F5 → Attach to Worker** (`.vscode/launch.json`) or open `http://localhost:4321/__debug`. Only one client at a time. Details: `docs/worker-debugging.md`.
## Testing
```bash
pnpm test # Vitest unit + component tests
pnpm storybook # Storybook visual tests (port 6006)
```
Stories live alongside their component (`ComponentName.stories.svelte`). Component tests use Vitest + `@storybook/addon-vitest`.
## Cloudflare Workers constraints
Runtime code (pages, components, services) must not use Node.js-only APIs (`fs`, `path`, `os`, `child_process`, etc.) — they are unavailable in the Cloudflare Workers runtime. Use Web APIs (`fetch`, `crypto`, `URL`, `Cache`) instead.
Build-time code (config files, scripts, plugins) may use Node.js APIs freely.
## SDK (`@hey-api`)
From package `@hey-api/openapi-ts`, using the `@hey-api/client-fetch` for runtime HTTP client.
### Files overview
| File | Edit? | Contents |
| ------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------- |
| `src/openapi/client/sdk.gen.ts` | No | Typed API functions — one per API operation |
| `src/openapi/client/types.gen.ts` | No | TypeScript types for all API resources |
| `src/openapi/client/operation-paths.gen.ts` | No | URL path string constants per operation |
| `src/openapi/client/client.gen.ts` | No | Singleton `client` instance |
| `src/openapi/api.ts` | Yes | `createClientConfig` (sets `baseUrl`), `AuthErrorType`, `createAuthError()`, `getErrorTranslationKey()` |
| `src/openapi/cacheFetch.ts` | Yes | Browser Cache API interceptor for GET requests |
| `src/openapi/plugins/operation-paths/` | Yes | Custom codegen plugin for URL path strings |
| `openapi-ts.config.ts` | Yes | Codegen config — input spec URL, output dir, plugins |
### Naming convention of generated functions
SDK functions follow the pattern `api{Resource}{Operation}`:
```bash
apiProjectsGetCollection() → GET /v4/projects
apiProjectsIdOrSlugGet() → GET /v4/projects/{idOrSlug}
apiUsersPost() → POST /v4/users
apiUsersIdpersonPatch() → PATCH /v4/users/{id}/person
```
Path constants (from `operation-paths`) follow the same pattern with `Url` suffix:
```bash
apiProjectsIdOrSlugGetUrl → '/v4/projects/{idOrSlug}'
apiAccountingsIdGetUrl → '/v4/accountings/{id}'
```
### Calling an API function
```typescript
import { apiProjectsGetCollection } from "../openapi/client/sdk.gen";
import type { Project } from "../openapi/client/types.gen";
const response = await apiProjectsGetCollection({
query: { page: 1, itemsPerPage: 20 },
headers: { "Accept-Language": locale },
});
const projects = (response.data as Project[]) || [];
```
Pass `path`, `query`, `body`, `headers` as named keys. All are typed from the OpenAPI spec.
For authenticated requests, pass the token via `headers`:
```typescript
await apiUsersIdpersonPatch({
path: { id: userId },
headers: { ...token.asHttpHeaders },
body: { firstName: "Ada" },
});
```
### `client.buildUrl()` — construct a URL without fetching
Use when you need the URL string (e.g. for manual `fetch` calls or cache keys):
```typescript
import { client } from "../openapi/client/client.gen";
import { apiProjectsIdOrSlugGetUrl } from "../openapi/client/operation-paths.gen";
const url = client.buildUrl({
url: apiProjectsIdOrSlugGetUrl,
path: { idOrSlug: "my-project" },
});
// → "https://api.example.com/v4/projects/my-project"
```
### Request interceptors
The `client` supports request/response interceptors via `client.interceptors.request.use()`. The browser cache interceptor is attached in `App.svelte` on mount:
```typescript
import { createBrowserCacheInterceptor } from "../openapi/cacheFetch";
import { client } from "../openapi/client/client.gen";
onMount(() => {
client.interceptors.request.use(createBrowserCacheInterceptor());
});
```
The interceptor caches GET responses in the browser Cache API (`"goteo-v4-api"` cache) and handles `ETag`/`Cache-Control`/`Expires` revalidation automatically.
### `src/utils/cachedFetch.ts` — auth-protected persistent cache helpers
Higher-level helpers that use `client.buildUrl()` + a persistent `caches.open()` cache for authenticated IRI fetches. Use these when fetching related resources from an IRI reference:
```typescript
import { fetchProject, fetchUser, fetchAccounting } from "../utils/cachedFetch";
const project = await fetchProject(projectIri, token, "my-cache");
const user = await fetchUser(userIri, token, "my-cache");
```
Available: `fetchProject`, `fetchUser`, `fetchAccounting`, `fetchCheckout`, `fetchTipjar`, `fetchWithPersistentCache<T>` (generic).
### Regenerating the SDK
```bash
pnpm openapi-ts
```
Config in `openapi-ts.config.ts` — fetches the OpenAPI spec from `$PUBLIC_API_URL/$PUBLIC_API_VERSION/docs.json`. Requires the API running and env vars set. Regenerate when the API spec changes; commit generated files together with the config change.
Active plugins: `defaultPlugins`, `operation-paths` (custom), `@hey-api/client-fetch`, `@hey-api/sdk`, `@hey-api/typescript` (enums as JS, `readOnlyWriteOnly` off).
### Key environment variables
| Variable | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------------ |
| `PUBLIC_API_URL` | API base URL |
| `PUBLIC_API_VERSION` | API version (currently `"v4"`) |
| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` | OAuth2 credentials (grants: authorization_code, refresh_token, password) |
| `PUBLIC_DEFAULT_LANGUAGE` | Default locale (`"es"`, `"en"`, or `"ca"`) |
| `PUBLIC_DEFAULT_CURRENCY` | Default currency (e.g. `"EUR"`) |
| `PUBLIC_DEFAULT_MAXSIZE` | Max upload size in bytes (default `8388608` = 8MB) |
| `PUBLIC_TIPPING_TIPJAR_ID` | Tipjar ID — leave empty to disable tipping |
| `PUBLIC_TIPPING_DEFAULT_AMOUNT` | Pre-filled tip amount |
| `PUBLIC_TIPPING_DEFAULT_CHECKED` | `"true"` to pre-check the tip option |
| `PUBLIC_FACEBOOK_APP_ID` | Facebook share dialog app ID |
| `BASIC_AUTH` | `"true"` to enable HTTP Basic auth layer |
| `BASIC_AUTH_USERNAME` / `BASIC_AUTH_PASSWORD` | Credentials for Basic auth |
| `OBJECT_STORAGE_ACCESS_KEY` | S3-compatible storage key |
| `OBJECT_STORAGE_SECRET_KEY` | S3-compatible storage secret |
| `OBJECT_STORAGE_REGION` | Storage region |
| `OBJECT_STORAGE_ENDPOINT` | Storage endpoint URL |
| `OBJECT_STORAGE_BUCKET` | Bucket name |
| `CLOUDFLARE_INCLUDE_PROCESS_ENV` | `"true"` — required for Cloudflare Workers to read env vars |
## Astro actions
Actions live in `src/actions/`. Export all actions from `src/actions/index.ts` under the `server` object:
```typescript
// src/actions/index.ts
export const server = { register, payment };
```
Define actions with `defineAction` + Zod input schema (import Zod from `astro/zod`):
```typescript
import { z } from "astro/zod";
import { ActionError, defineAction } from "astro:actions";
export const myAction = defineAction({
accept: "form", // or "json"
input: z.object({ name: z.string() }),
handler: async (input, context) => {
const { t, session } = context.locals;
if (!session)
throw new ActionError({ code: "UNAUTHORIZED", message: t("errors.unauthorized") });
return { success: true };
},
});
```
Call from a Svelte component:
```svelte
<script lang="ts">
import { actions } from "astro:actions";
async function handleSubmit(e: SubmitEvent) {
const form = e.target as HTMLFormElement;
const { data, error } = await actions.myAction(new FormData(form));
}
</script>
<form onsubmit|preventDefault={handleSubmit}>...</form>
```
Throw `ActionError` with i18n message for user-facing errors. Available error codes: `"BAD_REQUEST"`, `"UNAUTHORIZED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"INTERNAL_SERVER_ERROR"`.
## Image upload
Three-stage flow via internal API endpoints. Use the provided utilities — do not call S3 directly from client code.
**`uploadImage(file, options?)` from `src/utils/imageUpload.ts`:**
```typescript
import { uploadImage } from "../utils/imageUpload";
const result = await uploadImage(file, {
onProgress: (stage) => console.log(stage), // "preupload" | "uploading" | "postupload"
});
// result: { url: string, key: string }
```
Stages: `preupload` → POST `/api/upload/preupload` gets a signed S3 URL → `uploading` → PUT file directly to S3 → `postupload` → POST `/api/upload/postupload` validates, hashes, moves to public path.
**Svelte wrapper `createImageUploader()` from `src/utils/imageUpload.svelte.ts`** — exposes `$state` for reactive UI:
```svelte
<script lang="ts">
import { createImageUploader } from "../utils/imageUpload.svelte";
const uploader = createImageUploader();
</script>
<button onclick={() => uploader.upload(file)} disabled={uploader.uploading}>
{uploader.uploading ? uploader.stage : "Upload"}
</button>
{#if uploader.error}<p>{uploader.error}</p>{/if}
```
**`FileUpload.svelte` component** (`src/components/library/inputs/FileUpload.svelte`) handles drag-drop UI with validation:
```svelte
<FileUpload bind:files maxSizeMB={8} accept={["image/jpeg", "image/webp", "image/png"]} />
```
## API relay endpoint
`src/pages/api/relay/[...path].ts` is a server-side auth proxy. It adds the Bearer token from the session cookie and forwards requests to the v4 API — use it to make authenticated API calls from client-side code without exposing tokens.
The OpenAPI SDK client already handles auth internally; the relay is for edge cases where you need raw fetch with auth.
## Svelte stores
Client-side state. Subscribe with `$storeName` in templates.
| Store file | Key exports | Notes |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `src/auth/store.ts` | `session` | `Session \| undefined`, set by App.svelte |
| `src/stores/searchStore.ts` | `searchFilters`, `searchResults`, `isSearching`, `hasActiveFilters`, `paginationInfo`; `performSearch()`, `updateFilters()`, `loadNextPage()` | URL sync, debounce, abort |
| `src/stores/cart.ts` | `cartCount`, `cartAmount`, `cartByTarget`; `addItem()`, `removeItem()`, `updateQuantity()`, `clear()`, `clearTarget()` | `localStorage`, indexed by Accounting IRI + recipient |
| `src/stores/wizard-state.ts` | `isReadyToPublish`, `isCampaignInfoValidStore`; `navigateToStep()`, `initializeFromProject()`, `saveToLocalStorage()`, `restoreFromLocalStorage()` | 5-step wizard, 1s throttle |
| `src/stores/projectCache.ts` | `add()`, `addMany()`, `get(key)`, `has(key)`, `clear()` | Session-level in-memory cache, indexed by id/slug |
| `src/stores/chargesPaginationAndSort.ts` | `itemsPerPage`, `currentPage`, `totalItems`, `isLoading`, `SortOption[]` | Admin contributions pagination |
| `src/stores/drafts/projectDraft.ts` | — | Dexie (IndexedDB) draft; use `src/utils/drafts/db.ts` + `repository.ts` |
## Additional utilities
| Util | Import | Signature |
| -------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------- |
| `renderMarkdown(text)` | `src/utils/renderMarkdown.ts` | `async (raw: string) => Promise<string>` — HTML output, links open in new tab |
| `formatDate(date, locale)` | `src/utils/dates.ts` | Locale-aware date formatting via `Intl.DateTimeFormat` |
| `formatCurrency(value, currency?)` | `src/utils/currencies.ts` | Locale-aware currency via `Intl.NumberFormat` |
| `highlightMatch(text, query, markClass?)` | `src/utils/highlights.ts` | Wraps matched chars in `<mark>`, accent-insensitive (NFD) |
| `Unauthorized` / `NotFound` / `InternalSeverError` | `src/utils/responses.ts` | Pre-built `Response` objects for API endpoints |
| `uploadToObjectStorage()` | `src/utils/objectStorage.ts` | Server-side S3-compatible upload (Node/Workers build-time only) |
| `publishProject()` | `src/utils/projectPublisher.ts` | Submit a project draft to the API for publishing |
| `submitProjectData()` | `src/utils/projectSubmissionApi.ts` | Low-level API calls for project wizard step submission |
| `getTippingConfig()` | `src/utils/tipping.ts` | Read tipping env vars (`PUBLIC_TIPPING_*`) into a typed config object |
| `budgetColors` | `src/utils/budgetColors.ts` | Budget bar color palette constants |
| `getCategories()` | `src/utils/categories.ts` | Fetch + cache project categories from the API |
| `getLang()` | `src/utils/lang.ts` | Resolve locale string from URL or Accept-Language |
## `Thtml` component
`src/components/library/typography/Thtml.svelte` — renders a translation key that contains HTML. Avoids writing `{@html $t("key", {}, { allowHTML: true })}` inline:
```svelte
<Thtml key="footer.legal" vars={{ year: 2025 }} />
```
## Storybook
Stories use `@storybook/addon-svelte-csf`. File naming: `ComponentName.stories.svelte` alongside the component. Use `defineMeta` + `Story` from `@storybook/addon-svelte-csf`; add `tags: ["autodocs"]` for auto-generated docs. Place stories next to the component file.
No `pnpm test` script. Vitest runs through `@storybook/addon-vitest` (`pnpm exec vitest`). `pnpm storybook` runs on port 6006. Stories live next to the component as `ComponentName.stories.svelte`, using `defineMeta` + `Story` from `@storybook/addon-svelte-csf` with `tags: ["autodocs"]`.
+11 -3
View File
@@ -4,13 +4,14 @@ import svelte from "@astrojs/svelte";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, fontProviders, logHandlers } from "astro/config";
import { loadEnv } from "vite";
import { labels } from "./src/i18n/locales";
const locales = Object.keys(labels);
/**
* The API and the object storage live on their own hosts, and both change per deployment
* (`api-staging`, `api`, a local API…), so the CSP below has to be built from the environment
* (staging, production, a local API…), so the CSP below has to be built from the environment
* rather than hardcoded. This is build-time config, so reading `.env` from disk is fine.
*
* Astro has not loaded `.env` yet when this file is evaluated, and in CI there is no `.env`
@@ -119,8 +120,7 @@ export default defineConfig({
// Login and checkout forms post to us; nothing should post elsewhere.
"form-action 'self'",
/**
* Clickjacking: nobody frames us. The shareable project widget lives on
* `www.goteo.org` (v3), not here, so no route on this origin is meant to be
* Clickjacking: nobody frames us. No route on this origin is meant to be
* embedded elsewhere. Worth revisiting the day one is.
*/
"frame-ancestors 'self'",
@@ -196,6 +196,14 @@ export default defineConfig({
styles: ["normal", "italic"],
subsets: ["latin", "latin-ext"],
},
{
provider: fontProviders.google(),
name: "Boldonse",
cssVariable: "--font-boldonse",
weights: ["200 800"],
styles: ["normal", "italic"],
subsets: ["latin", "latin-ext"],
},
],
vite: {
@@ -0,0 +1,9 @@
-- The hero base row can be written in any of the supported locales (the admin
-- UI language at save time), so record which one it is. Translation rows in the
-- `translations` table cover the remaining locales.
ALTER TABLE home_hero ADD COLUMN language TEXT NOT NULL DEFAULT '';
UPDATE home_hero
SET language = 'es'
WHERE language = ''
AND title = '';
+1 -1
View File
@@ -1,2 +1,2 @@
-- Seed data for local development
-- Apply with: 'npx wrangler d1 execute goteo-db --local --file=./db/seed.sql' or just 'pnpm db:seed'
-- Apply with: 'pnpm db:seed'
+7 -11
View File
@@ -1,30 +1,26 @@
{
"name": "goteo",
"name": "dtfcrowd",
"version": "0.0.1",
"type": "module",
"scripts": {
"astro": "astro",
"build": "astro build",
"build:testing": "CLOUDFLARE_ENV=testing astro build --mode testing",
"build:staging": "CLOUDFLARE_ENV=staging astro build --mode staging",
"build:prod": "CLOUDFLARE_ENV=prod astro build --mode prod",
"deploy:testing": "pnpm build:testing && pnpm wrangler --env testing d1 migrations apply goteov4web-db-testing --remote && wrangler deploy --env testing",
"deploy:staging": "pnpm build:staging && pnpm wrangler --env staging d1 migrations apply goteov4web-db-staging --remote && wrangler deploy --env staging",
"deploy:prod": "pnpm build:prod && pnpm wrangler --env prod d1 migrations apply goteov4web-db-prod --remote && wrangler deploy --env prod",
"check": "pnpm check:format:prettier",
"deploy:staging": "pnpm build:staging && pnpm wrangler --env staging d1 migrations apply dtfcrowd-db-staging --remote && wrangler deploy --env staging",
"deploy:prod": "pnpm build:prod && pnpm wrangler --env prod d1 migrations apply dtfcrowd-db-prod --remote && wrangler deploy --env prod",
"check": "prettier --check . --plugin prettier-plugin-astro --plugin prettier-plugin-svelte",
"check:astro": "astro check",
"check:format:prettier": "prettier --check . --plugin prettier-plugin-astro --plugin prettier-plugin-svelte",
"check:svelte": "svelte-check --tsconfig ./tsconfig.json",
"check:types": "pnpm check:astro && pnpm check:svelte",
"db:migrate": "wrangler d1 migrations apply goteov4web-db --local",
"db:seed": "wrangler d1 execute goteov4web-db --local --file=./db/seed.sql",
"db:migrate": "wrangler d1 migrations apply dtfcrowd-db --local",
"db:seed": "wrangler d1 execute dtfcrowd-db --local --file=./db/seed.sql",
"dev": "astro dev",
"format": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint --fix . && prettier --write .",
"format:eslint": "eslint --fix . ",
"format:prettier": "prettier --write .",
"openapi": "openapi-ts",
"sdk": "openapi-ts",
"preview": "astro preview",
"preview:ci": "NODE_ENV=test NODE_TLS_REJECT_UNAUTHORIZED=0 SKIP_PAYMENT_GATEWAYS=true PAYMENT_GATEWAYS_ENABLED=false wrangler dev --env preview --port 4321 --ip 0.0.0.0",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 600 200">
<!-- Generator: Adobe Illustrator 30.8.2, SVG Export Plug-In . SVG Version: 2.1.4 Build 7) -->
<defs>
<style>
.st0 {
fill: #fd4d9b;
}
.st0, .st1, .st2 {
isolation: isolate;
}
.st0, .st2 {
font-family: Boldonse-Regular, Boldonse;
font-size: 42.34px;
}
.st2 {
fill: #fff;
}
</style>
</defs>
<g class="st1">
<text class="st2" transform="translate(16.01 121.98)"><tspan x="0" y="0">democratic.</tspan></text>
</g>
<text class="st0" transform="translate(338.58 121.98)"><tspan x="0" y="0">tech</tspan></text>
<g class="st1">
<text class="st2" transform="translate(467.57 121.98)"><tspan x="0" y="0">.fund</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 879 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="400" height="568" viewBox="0 0 400 568" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.08" d="M199.36 0.0544008C200.771 -0.241646 200.918 0.720502 201.016 1.73199C224.741 75.9534 259.09 124.542 303.695 174.932C346.374 223.138 395.726 268.791 399.762 353.127C402.927 419.404 374.061 472.989 343.454 506.331C309.301 543.559 253.852 569.179 196.048 567.958C136.624 566.688 87.9835 540.339 53.61 501.348C19.3715 462.516 -4.15758 413.36 0.614467 346.454C7.6192 248.117 81.1014 200.096 128.147 138.296C158.73 98.0828 180.076 56.4883 199.36 0.0544008ZM76.7956 366.461C77.3844 411.35 101.527 452.327 134.772 473.051C181.67 502.273 246.909 494.785 285.49 456.373C305.854 436.119 319.68 407.846 323.581 366.461C272.082 366.461 260.329 365.697 200.489 365.697C200.489 324.608 201.028 280.953 201.028 239.889C129.472 239.704 75.9246 299.012 76.7956 366.461Z" fill="#462949"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="170"
height="58"
viewBox="0 0 170 58"
sodipodi:docname="eCommons.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.0205882"
inkscape:cx="74.483263"
inkscape:cy="86.360992"
inkscape:window-width="1374"
inkscape:window-height="831"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1" /><g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g1"><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.0258px;font-family:Sans;-inkscape-font-specification:Sans;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#ffffff;stroke-width:1.75215"
x="53.296333"
y="35.925053"
id="text2"><tspan
sodipodi:role="line"
id="tspan2"
x="53.296333"
y="35.925053"
style="fill:#000000;stroke-width:1.75215">eCommons</tspan></text><g
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5"
id="g2"
transform="matrix(0.08310294,0,0,0.08310294,3.2792928,2.166006)"><g
id="g1-5"><path
id="C-version-rounded-edges"
serif:id="C version rounded edges"
d="m 443.83,369.168 159.462,79.731 C 553.094,557.222 443.369,632.433 316.217,632.433 141.692,632.433 0,490.741 0,316.216 0,141.691 141.692,-0.001 316.217,-0.001 c 127.153,0 236.877,75.211 287.075,183.534 l -159.537,79.769 c -20.816,-50.023 -70.181,-85.23 -127.705,-85.23 -76.304,0 -138.253,61.949 -138.253,138.253 0,76.304 61.949,138.253 138.253,138.253 57.593,0 107.008,-35.292 127.78,-85.411 z m 17.361,58.992 c -33.519,43.43 -86.088,71.42 -145.14,71.42 -101.14,0 -183.253,-82.113 -183.253,-183.253 0,-101.14 82.113,-183.253 183.253,-183.253 58.981,0 111.494,27.922 145.019,71.262 l 79.869,-39.934 C 492.152,92.382 409.677,45.002 316.218,45.002 166.529,45.002 45.001,166.53 45.001,316.219 c 0,149.689 121.528,271.217 271.217,271.217 93.459,0 175.934,-47.38 224.721,-119.4 l -79.747,-39.874 z" /><g
id="e-motion-1"
serif:id="e-motion 1"><path
d="M 564.967,177.536 172.986,377.989"
style="fill:none;stroke:#000000;stroke-width:45px"
id="path1" /></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+148
View File
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
id="svg4866"
viewBox="0 0 134.84065 73.825632"
height="20.835234mm"
width="38.055027mm">
<defs
id="defs4868" />
<metadata
id="metadata4871">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(-150.58897,-548.94262)"
id="layer1">
<g
id="g5913-9"
transform="translate(139.99706,399.80165)">
<g
id="g5883-3">
<path
d="m 58.782852,173.80616 -1.51513,0 c -1.928349,0 -2.754783,1.188 -2.754783,3.06469 l 0,2.13496 -1.136348,0 0,1.61844 1.136348,0 0,6.09495 1.928348,0 0,-6.09495 1.342956,0 0,-1.61844 -1.342956,0 0,-2.25548 c 0.03443,-0.67148 0.189391,-1.13635 0.809217,-1.13635 l 1.532348,0 0,-1.80782 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5817-0" />
<path
d="m 64.54028,181.00303 0.637043,-1.63565 c -0.447652,-0.396 -1.101913,-0.53374 -1.325739,-0.53374 -1.050261,0 -1.738957,0.396 -2.186609,1.25687 l 0,-1.0847 -1.876696,0 0,7.71339 1.876696,0 0,-3.97721 c 0.03444,-1.25687 0.619826,-2.01444 1.807826,-2.01444 0.309913,0 0.757566,0.12052 1.067479,0.27548 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5819-8" />
<path
d="m 69.868348,178.83364 c -2.289913,0 -4.02887,1.79061 -4.02887,4.0633 0,2.25548 1.825044,4.01166 4.114957,4.01166 1.463478,-0.0172 2.789218,-0.61983 3.529566,-1.70453 l -1.222435,-1.01582 c -0.568174,0.63704 -1.291305,1.01582 -2.221044,1.01582 l -0.06887,0 c -1.188,0 -2.152174,-0.94695 -2.152174,-2.01443 l 5.836697,0 0,-0.70592 c 0,-2.25547 -1.515131,-3.65008 -3.70174,-3.65008 l -0.08609,0 z m 1.842261,2.8753 -3.718957,0 c 0.137739,-0.46487 1.015826,-1.11913 1.893913,-1.11913 0.878087,0 1.65287,0.2927 1.825044,1.11913 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5821-7" />
<path
d="m 79.410912,178.83364 c -2.289913,0 -4.02887,1.79061 -4.02887,4.0633 0,2.25548 1.825044,4.01166 4.114957,4.01166 1.463479,-0.0172 2.789218,-0.61983 3.529566,-1.70453 l -1.222435,-1.01582 c -0.568174,0.63704 -1.291304,1.01582 -2.221044,1.01582 l -0.06887,0 c -1.188,0 -2.152174,-0.94695 -2.152174,-2.01443 l 5.836696,0 0,-0.70592 c 0,-2.25547 -1.515131,-3.65008 -3.70174,-3.65008 l -0.08609,0 z m 1.842262,2.8753 -3.718958,0 c 0.13774,-0.46487 1.015827,-1.11913 1.893914,-1.11913 0.878087,0 1.65287,0.2927 1.825044,1.11913 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5823-6" />
</g>
<g
id="g5889-52"
transform="translate(0.40571565,0)">
<path
d="m 53.944765,187.58007 0,12.91304 1.876695,0 0,-3.44347 3.409044,3.44347 2.289914,0 -4.183827,-4.20104 4.011653,-3.51235 -2.255479,0 -3.271305,2.80644 0,-8.00609 -1.876695,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5825-4" />
<path
d="m 70.329897,195.98216 c 0,-2.23826 -1.084696,-3.37461 -3.081914,-3.37461 -0.929739,0 -1.601217,0.41322 -2.134956,1.25687 l 0,-1.05026 -1.928349,0 0,7.67895 1.928349,0 0,-4.23547 c 0.03443,-1.06748 0.688695,-1.75618 1.859478,-1.75618 0.843652,0.0172 1.239653,0.53374 1.239653,1.51513 l 0,4.47652 2.117739,0 0,-4.51095 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5827-0" />
<path
d="m 76.546275,192.60755 c -2.289913,0 -4.149392,1.79061 -4.149392,4.0633 0,2.25548 1.859479,4.01166 4.149392,4.01166 2.272696,0 4.09774,-1.75618 4.09774,-4.02887 0,-2.2727 -1.825044,-4.04609 -4.09774,-4.04609 z m 0.01722,1.75617 c 1.101913,0 1.997217,1.0847 1.997217,2.30713 0,1.23966 -0.895304,2.25548 -1.997217,2.25548 -1.119131,0 -2.014435,-1.01582 -2.014435,-2.25548 0,-1.22243 0.895304,-2.30713 2.014435,-2.30713 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5829-06" />
<path
d="m 89.56837,200.66529 3.85669,-7.88557 -2.01443,0 -1.96279,4.08052 -2.066083,-4.08052 -1.997218,4.08052 -1.962783,-4.08052 -1.997217,0 3.856696,7.88557 0.206608,0 1.893914,-4.28713 1.962783,4.28713 0.22383,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5831-8" />
<path
d="m 95.14152,187.58007 0,12.91304 1.92834,0 0,-12.91304 -1.92834,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5833-5" />
<path
d="m 103.27117,192.60755 c -2.28991,0 -4.02887,1.79061 -4.02887,4.0633 0,2.25548 1.82505,4.01166 4.11496,4.01166 1.46348,-0.0172 2.78922,-0.61983 3.52957,-1.70453 l -1.22244,-1.01582 c -0.56817,0.63704 -1.2913,1.01582 -2.22104,1.01582 l -0.0689,0 c -1.188,0 -2.15218,-0.94695 -2.15218,-2.01443 l 5.8367,0 0,-0.70591 c 0,-2.25548 -1.51513,-3.65009 -3.70174,-3.65009 l -0.0861,0 z m 1.84227,2.8753 -3.71896,0 c 0.13774,-0.46487 1.01582,-1.11913 1.89391,-1.11913 0.87809,0 1.65287,0.2927 1.82505,1.11913 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5835-4" />
<path
d="m 114.63878,193.82998 c -0.44765,-0.91252 -1.17078,-1.22243 -2.11774,-1.22243 l -0.0344,0 c -1.91113,0 -3.58122,1.41182 -3.59844,3.82226 0.0172,2.73757 1.49792,4.2527 3.63287,4.2527 0.99861,0 1.68731,-0.43044 2.11774,-1.25687 l 0,1.06747 1.89392,0 0,-12.91304 -1.89392,0 0,6.24991 z m -3.46069,2.59983 c 0,-1.32574 0.63704,-1.92835 1.61843,-1.92835 1.15357,0 1.84226,0.67148 1.84226,2.08331 0,1.3946 -0.68869,2.25547 -1.84226,2.25547 l -0.0516,0 c -1.10191,0 -1.56678,-1.03304 -1.56678,-2.41043 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5837-94" />
<path
d="m 124.43073,196.72251 c 0,1.37739 -0.65426,2.11773 -1.77339,2.11773 -1.03304,0 -1.584,-0.77478 -1.584,-2.15217 0,-1.37739 0.58539,-2.18661 1.584,-2.18661 1.13635,0 1.77339,0.87809 1.77339,2.22105 z m 2.03165,3.71895 0,-7.66174 -2.03165,0 0,1.10192 c -0.44765,-0.87809 -1.10191,-1.27409 -2.04887,-1.27409 -2.16939,0 -3.59843,1.65287 -3.59843,4.08052 0,2.35878 1.48069,3.99444 3.59843,3.99444 0.94696,0 1.63565,-0.41322 2.04887,-1.11913 l 0,1.01582 c 0,1.27409 -0.74035,1.8767 -2.06608,1.8767 -0.77479,0 -1.41183,-0.36157 -1.79061,-0.89531 l -1.44626,1.20522 c 0.75756,1.17078 2.30713,1.60122 3.40904,1.60122 2.54817,-0.0344 3.92556,-1.30852 3.92556,-3.92557 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5839-6" />
<path
d="m 132.6723,192.60755 c -2.28991,0 -4.02887,1.79061 -4.02887,4.0633 0,2.25548 1.82504,4.01166 4.11496,4.01166 1.46348,-0.0172 2.78922,-0.61983 3.52956,-1.70453 l -1.22243,-1.01582 c -0.56818,0.63704 -1.29131,1.01582 -2.22104,1.01582 l -0.0689,0 c -1.188,0 -2.15218,-0.94695 -2.15218,-2.01443 l 5.8367,0 0,-0.70591 c 0,-2.25548 -1.51513,-3.65009 -3.70174,-3.65009 l -0.0861,0 z m 1.84226,2.8753 -3.71895,0 c 0.13773,-0.46487 1.01582,-1.11913 1.89391,-1.11913 0.87809,0 1.65287,0.2927 1.82504,1.11913 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:17.21739388px;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#009245;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5841-8" />
</g>
<g
id="g5900-3"
transform="translate(0.50714457,0)">
<path
d="m 53.944765,206.55363 0,7.71339 1.91113,0 0,-7.71339 -1.91113,0 z m 0.964174,-4.08052 c -0.688696,0 -1.222435,0.51652 -1.222435,1.188 0,0.67148 0.533739,1.188 1.222435,1.188 0.688695,0 1.222435,-0.51652 1.222435,-1.188 0,-0.67148 -0.53374,-1.188 -1.222435,-1.188 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5843-2" />
<path
d="m 65.622016,209.75607 c 0,-2.23826 -1.084695,-3.37461 -3.081913,-3.37461 -0.929739,0 -1.601218,0.41322 -2.134957,1.25687 l 0,-1.05026 -1.928348,0 0,7.67895 1.928348,0 0,-4.23547 c 0.03444,-1.06748 0.688696,-1.75618 1.859479,-1.75618 0.843652,0.0172 1.239652,0.53374 1.239652,1.51513 l 0,4.47652 2.117739,0 0,-4.51095 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5845-3" />
<path
d="m 73.766742,208.03433 c -0.602608,-0.96418 -1.566782,-1.65287 -2.720348,-1.65287 l -0.08609,0 c -1.842261,0 -2.944174,1.06748 -2.944174,2.376 0,1.34296 1.136348,1.99722 2.238261,2.23826 0.774783,0.17217 1.825044,0.48209 1.825044,1.03304 l 0,0.0172 c 0,0.43044 -0.619826,0.65426 -1.32574,0.65426 -0.585391,0 -1.067478,-0.27548 -1.532348,-0.98139 l -1.463478,1.17078 c 0.878087,1.29131 2.083305,1.60122 2.995826,1.60122 1.601218,0 3.133566,-0.8953 3.185218,-2.44487 -0.01722,-1.30852 -0.946956,-2.04887 -2.117739,-2.34156 -0.688696,-0.15496 -1.893913,-0.46487 -1.911131,-0.96418 0,-0.55096 0.396,-0.77478 1.033044,-0.792 0.654261,0 1.274087,0.70591 1.51513,1.03305 l 1.308522,-0.94696 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5847-6" />
<path
d="m 78.507537,203.50615 -1.859479,0 0,3.04748 -0.981391,0 0,1.60122 0.981391,0 0,6.11217 1.859479,0 0,-6.11217 0.964174,0 0,-1.60122 -0.964174,0 0,-3.04748 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5849-2" />
<path
d="m 81.589542,206.55363 0,7.71339 1.911131,0 0,-7.71339 -1.911131,0 z m 0.964174,-4.08052 c -0.688696,0 -1.222435,0.51652 -1.222435,1.188 0,0.67148 0.533739,1.188 1.222435,1.188 0.688696,0 1.222435,-0.51652 1.222435,-1.188 0,-0.67148 -0.533739,-1.188 -1.222435,-1.188 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5851-2" />
<path
d="m 88.445924,203.50615 -1.859479,0 0,3.04748 -0.981391,0 0,1.60122 0.981391,0 0,6.11217 1.859479,0 0,-6.11217 0.964176,0 0,-1.60122 -0.964176,0 0,-3.04748 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5853-4" />
<path
d="m 91.42462,211.09902 c 0,2.23827 1.63566,3.3574 3.61566,3.3574 l 0.0861,0 c 2.08331,0 3.4607,-1.27409 3.4607,-3.3574 l 0,-4.54539 -2.10052,0 0,4.26992 c -0.0172,1.23965 -0.43044,1.79061 -1.44626,1.79061 -0.89531,0 -1.54957,-0.5854 -1.54957,-1.56679 l 0,-4.49374 -2.06609,0 0,4.54539 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5855-70" />
<path
d="m 103.4621,203.50615 -1.85948,0 0,3.04748 -0.98139,0 0,1.60122 0.98139,0 0,6.11217 1.85948,0 0,-6.11217 0.96417,0 0,-1.60122 -0.96417,0 0,-3.04748 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5857-4" />
<path
d="m 109.90634,206.38146 c -2.28992,0 -4.02887,1.79061 -4.02887,4.0633 0,2.25548 1.82504,4.01166 4.11495,4.01166 1.46348,-0.0172 2.78922,-0.61983 3.52957,-1.70453 l -1.22244,-1.01582 c -0.56817,0.63704 -1.2913,1.01582 -2.22104,1.01582 l -0.0689,0 c -1.188,0 -2.15217,-0.94695 -2.15217,-2.01443 l 5.83669,0 0,-0.70591 c 0,-2.25548 -1.51513,-3.65009 -3.70174,-3.65009 l -0.0861,0 z m 1.84226,2.8753 -3.71896,0 c 0.13774,-0.46487 1.01583,-1.11913 1.89391,-1.11913 0.87809,0 1.65287,0.2927 1.82505,1.11913 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:80.00000119%;font-family:'Universalis ADF Std';-inkscape-font-specification:'Universalis ADF Std Bold';letter-spacing:0.26131234px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path5859-9" />
</g>
<g
id="g4522-8-8-6"
transform="translate(-29.238005,-332.75021)">
<path
style="opacity:1;fill:#666666;fill-opacity:1;stroke:none;stroke-width:3.29999995;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 62.920803,490.8298 a 14.922076,14.922076 0 0 0 -0.216893,0.0106 l 0,5.84398 a 9.0742365,9.0742365 0 0 1 0.216893,-0.008 9.0742365,9.0742365 0 0 1 9.074614,9.07463 9.0742365,9.0742365 0 0 1 -9.074614,9.07461 9.0742365,9.0742365 0 0 1 -9.074614,-9.07461 9.0742365,9.0742365 0 0 1 0.0015,-0.0303 l -5.847006,0 a 14.922076,14.922076 0 0 0 -0.0015,0.0303 14.922076,14.922076 0 0 0 14.921622,14.92206 14.922076,14.922076 0 0 0 14.923134,-14.92165 14.922076,14.922076 0 0 0 -14.923136,-14.9216 z"
id="path4484-9-3-9-5" />
<circle
r="4.2579489"
cy="496.78735"
cx="54.405663"
id="circle4488-60-6-4-4"
style="opacity:1;fill:#009245;fill-opacity:1;stroke:none;stroke-width:3.29999995;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="801" height="245" viewBox="0 0 801 245" fill="none">
<path d="M0.295532 143.456V31.9801H16.543V48.8293L14.5873 44.9179C17.5961 40.3044 21.6078 36.6938 26.6224 34.0862C31.6371 31.4786 37.3538 30.1748 43.7725 30.1748C51.4951 30.1748 58.4153 32.0302 64.5332 35.7411C70.7513 39.4519 75.6156 44.5167 79.1258 50.9354C82.7364 57.3542 84.5416 64.5753 84.5416 72.5987C84.5416 80.6222 82.7364 87.8433 79.1258 94.262C75.6156 100.681 70.8015 105.796 64.6836 109.607C58.5657 113.318 51.5954 115.173 43.7725 115.173C37.4541 115.173 31.6872 113.869 26.472 111.262C21.3571 108.654 17.3955 104.893 14.5873 99.9787L17.2952 96.8195V143.456H0.295532ZM42.1177 99.377C46.9318 99.377 51.1942 98.2236 54.9051 95.9168C58.6159 93.6101 61.5244 90.4509 63.6305 86.4392C65.837 82.3271 66.9402 77.7137 66.9402 72.5987C66.9402 67.4838 65.837 62.9205 63.6305 58.9087C61.5244 54.897 58.6159 51.7378 54.9051 49.4311C51.1942 47.1243 46.9318 45.9709 42.1177 45.9709C37.4039 45.9709 33.1415 47.1243 29.3303 49.4311C25.6195 51.7378 22.6609 54.897 20.4544 58.9087C18.3483 62.9205 17.2952 67.4838 17.2952 72.5987C17.2952 77.7137 18.3483 82.3271 20.4544 86.4392C22.6609 90.4509 25.6195 93.6101 29.3303 95.9168C33.1415 98.2236 37.4039 99.377 42.1177 99.377Z" fill="#141414"/>
<path d="M641.82 244.518V133.043H658.068V149.892L656.112 145.98C659.121 141.367 663.132 137.756 668.147 135.149C673.162 132.541 678.878 131.237 685.297 131.237C693.02 131.237 699.94 133.093 706.058 136.804C712.276 140.514 717.14 145.579 720.65 151.998C724.261 158.417 726.066 165.638 726.066 173.661C726.066 181.685 724.261 188.906 720.65 195.325C717.14 201.743 712.326 206.858 706.208 210.669C700.09 214.38 693.12 216.236 685.297 216.236C678.979 216.236 673.212 214.932 667.997 212.324C662.882 209.717 658.92 205.956 656.112 201.041L658.82 197.882V244.518H641.82ZM683.642 200.439C688.456 200.439 692.719 199.286 696.43 196.979C700.14 194.673 703.049 191.513 705.155 187.502C707.362 183.39 708.465 178.776 708.465 173.661C708.465 168.546 707.362 163.983 705.155 159.971C703.049 155.96 700.14 152.8 696.43 150.494C692.719 148.187 688.456 147.033 683.642 147.033C678.928 147.033 674.666 148.187 670.855 150.494C667.144 152.8 664.185 155.96 661.979 159.971C659.873 163.983 658.82 168.546 658.82 173.661C658.82 178.776 659.873 183.39 661.979 187.502C664.185 191.513 667.144 194.673 670.855 196.979C674.666 199.286 678.928 200.439 683.642 200.439Z" fill="#141414"/>
<path d="M770.057 216.236C761.733 216.236 754.412 214.18 748.093 210.068C741.875 205.956 737.512 200.439 735.005 193.519L748.093 187.351C750.3 191.965 753.308 195.625 757.119 198.333C761.031 201.041 765.343 202.395 770.057 202.395C774.069 202.395 777.329 201.493 779.836 199.687C782.343 197.882 783.597 195.425 783.597 192.316C783.597 190.31 783.045 188.705 781.942 187.502C780.839 186.198 779.435 185.145 777.73 184.342C776.125 183.54 774.47 182.938 772.765 182.537L759.978 178.927C752.957 176.921 747.692 173.912 744.182 169.9C740.772 165.788 739.067 161.024 739.067 155.608C739.067 150.694 740.32 146.432 742.828 142.821C745.335 139.11 748.795 136.252 753.208 134.246C757.621 132.24 762.585 131.237 768.102 131.237C775.523 131.237 782.143 133.093 787.96 136.804C793.777 140.414 797.889 145.479 800.296 151.998L787.207 158.166C785.603 154.255 783.045 151.145 779.535 148.839C776.125 146.532 772.264 145.379 767.951 145.379C764.24 145.379 761.282 146.281 759.075 148.087C756.869 149.792 755.766 152.048 755.766 154.856C755.766 156.762 756.267 158.367 757.27 159.67C758.273 160.874 759.577 161.877 761.181 162.679C762.786 163.381 764.441 163.983 766.146 164.484L779.385 168.396C786.104 170.301 791.269 173.31 794.88 177.422C798.49 181.434 800.296 186.248 800.296 191.864C800.296 196.678 798.992 200.941 796.384 204.652C793.877 208.262 790.367 211.121 785.853 213.227C781.34 215.233 776.075 216.236 770.057 216.236Z" fill="#141414"/>
<path d="M286.863 216.236C278.739 216.236 271.518 214.38 265.199 210.669C258.981 206.858 254.017 201.743 250.306 195.325C246.695 188.906 244.89 181.635 244.89 173.511C244.89 165.487 246.695 158.266 250.306 151.848C253.917 145.429 258.881 140.414 265.199 136.804C271.518 133.093 278.739 131.237 286.863 131.237C292.379 131.237 297.544 132.24 302.358 134.246C307.172 136.152 311.334 138.809 314.844 142.219C318.455 145.629 321.113 149.591 322.818 154.104L307.924 161.024C306.219 156.812 303.461 153.452 299.65 150.945C295.939 148.337 291.677 147.033 286.863 147.033C282.249 147.033 278.087 148.187 274.376 150.494C270.766 152.7 267.907 155.859 265.801 159.971C263.695 163.983 262.642 168.546 262.642 173.661C262.642 178.776 263.695 183.39 265.801 187.502C267.907 191.513 270.766 194.673 274.376 196.979C278.087 199.286 282.249 200.439 286.863 200.439C291.777 200.439 296.04 199.186 299.65 196.678C303.361 194.071 306.119 190.611 307.924 186.298L322.818 193.369C321.213 197.681 318.605 201.593 314.995 205.103C311.485 208.513 307.323 211.221 302.508 213.227C297.694 215.233 292.479 216.236 286.863 216.236Z" fill="#141414"/>
<path d="M376.698 216.236C368.875 216.236 361.704 214.38 355.185 210.669C348.766 206.959 343.651 201.894 339.84 195.475C336.029 189.056 334.124 181.785 334.124 173.661C334.124 165.437 336.029 158.166 339.84 151.848C343.651 145.429 348.766 140.414 355.185 136.804C361.604 133.093 368.775 131.237 376.698 131.237C384.721 131.237 391.892 133.093 398.211 136.804C404.63 140.414 409.694 145.429 413.405 151.848C417.216 158.166 419.122 165.437 419.122 173.661C419.122 181.885 417.216 189.207 413.405 195.625C409.594 202.044 404.479 207.109 398.06 210.82C391.642 214.43 384.521 216.236 376.698 216.236ZM376.698 200.439C381.512 200.439 385.775 199.286 389.485 196.979C393.196 194.673 396.105 191.513 398.211 187.502C400.417 183.39 401.521 178.776 401.521 173.661C401.521 168.546 400.417 163.983 398.211 159.971C396.105 155.96 393.196 152.8 389.485 150.494C385.775 148.187 381.512 147.033 376.698 147.033C371.984 147.033 367.722 148.187 363.911 150.494C360.2 152.8 357.241 155.96 355.035 159.971C352.929 163.983 351.876 168.546 351.876 173.661C351.876 178.776 352.929 183.39 355.035 187.502C357.241 191.513 360.2 194.673 363.911 196.979C367.722 199.286 371.984 200.439 376.698 200.439Z" fill="#141414"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M585.897 131.237C593.92 131.237 601.091 133.093 607.409 136.804C613.828 140.414 618.893 145.429 622.604 151.848C626.415 158.166 628.32 165.437 628.32 173.661C628.32 181.885 626.415 189.207 622.604 195.625C618.793 202.044 613.678 207.109 607.259 210.82C600.84 214.43 593.719 216.236 585.897 216.236H475.727C467.904 216.236 460.733 214.38 454.214 210.669C447.795 206.958 442.68 201.894 438.869 195.475C435.058 189.056 433.152 181.785 433.152 173.661C433.152 165.437 435.058 158.166 438.869 151.848C442.68 145.429 447.795 140.414 454.214 136.804C460.633 133.093 467.804 131.237 475.727 131.237H585.897ZM598.684 196.979C594.973 199.286 590.711 200.439 585.897 200.439H475.727C471.013 200.439 466.75 199.286 462.939 196.979C459.228 194.673 456.27 191.513 454.063 187.502C451.957 183.39 450.904 178.776 450.904 173.661C450.904 168.546 451.957 163.983 454.063 159.971C456.27 155.96 459.228 152.8 462.939 150.494C466.75 148.187 471.013 147.033 475.727 147.033H585.897C590.711 147.033 594.973 148.187 598.684 150.494C602.395 152.8 605.303 155.96 607.409 159.971C609.616 163.983 610.719 168.546 610.719 173.661C610.719 178.776 609.616 183.39 607.409 187.502C605.303 191.513 602.395 194.673 598.684 196.979Z" fill="#141414"/>
<path d="M121.961 115.268C113.737 115.268 107.568 113.412 103.456 109.701C99.4448 105.991 97.4389 100.324 97.4389 92.7018V0.482422H114.439V91.3478C114.439 97.4657 117.748 100.525 124.368 100.525H125.27V115.268H121.961Z" fill="#141414"/>
<path d="M164.752 116.172C159.236 116.172 154.371 115.22 150.159 113.314C146.047 111.308 142.838 108.6 140.531 105.19C138.224 101.68 137.071 97.5681 137.071 92.8544C137.071 88.4415 138.024 84.4799 139.929 80.9697C141.935 77.4594 144.994 74.5008 149.106 72.0937C153.218 69.6867 158.383 67.9817 164.601 66.9788L192.884 62.3152V75.7043L167.911 80.067C163.398 80.8694 160.088 82.3236 157.982 84.4298C155.876 86.4356 154.823 89.0433 154.823 92.2526C154.823 95.3617 155.976 97.9192 158.283 99.925C160.69 101.831 163.749 102.783 167.46 102.783C172.073 102.783 176.085 101.78 179.495 99.7746C183.005 97.7687 185.713 95.111 187.618 91.8013C189.524 88.3913 190.477 84.6304 190.477 80.5184V65.324C190.477 60.1087 188.922 55.9967 185.813 52.9879C182.704 49.8789 178.291 48.3243 172.575 48.3243H145.345V32.9795H173.327C180.447 32.9795 186.515 34.2833 191.53 36.8909C196.645 39.3982 200.556 43.0088 203.264 47.7226C206.072 52.4363 207.476 58.1029 207.476 64.7222V114.367H191.229V99.6242L194.689 100.075C192.784 103.385 190.326 106.243 187.318 108.651C184.409 111.058 181.049 112.913 177.238 114.217C173.527 115.521 169.365 116.172 164.752 116.172Z" fill="#141414"/>
<path d="M257.138 115.266C248.613 115.266 241.994 112.859 237.28 108.045C232.566 103.231 230.209 96.4612 230.209 87.7357V48.3206H215.917V32.9758H218.174C221.985 32.9758 224.944 31.8726 227.05 29.6661C229.156 27.4597 230.209 24.4509 230.209 20.6397V14.3213H247.209V32.9758H265.713V48.3206H247.209V86.9835C247.209 89.7917 247.66 92.1987 248.563 94.2046C249.465 96.1102 250.92 97.6146 252.926 98.7178C254.931 99.7207 257.539 100.222 260.748 100.222C261.551 100.222 262.453 100.172 263.456 100.072C264.459 99.9715 265.412 99.8712 266.315 99.7709V114.363C264.911 114.564 263.356 114.765 261.651 114.965C259.946 115.166 258.442 115.266 257.138 115.266Z" fill="#141414"/>
<path d="M293.06 114.365V48.3222H278.017V32.9773H293.06V28.765C293.06 22.6472 294.314 17.5322 296.821 13.4202C299.329 9.20791 302.789 5.99853 307.202 3.79209C311.715 1.58564 316.88 0.482422 322.697 0.482422C323.8 0.482422 325.054 0.582714 326.458 0.783297C327.862 0.883594 329.016 1.03403 329.918 1.23462V15.9777C329.116 15.7771 328.213 15.6768 327.21 15.6768C326.207 15.5765 325.405 15.5264 324.803 15.5264C320.29 15.5264 316.679 16.5794 313.972 18.6856C311.264 20.6914 309.91 24.0513 309.91 28.765V32.9773H328.564V48.3222H309.91V114.365H293.06Z" fill="#141414"/>
<path d="M376.699 116.169C368.876 116.169 361.705 114.314 355.186 110.603C348.767 106.892 343.652 101.827 339.841 95.4085C336.03 88.9898 334.125 81.7185 334.125 73.5948C334.125 65.3708 336.03 58.0996 339.841 51.7811C343.652 45.3624 348.767 40.3477 355.186 36.7372C361.605 33.0263 368.776 31.1709 376.699 31.1709C384.722 31.1709 391.893 33.0263 398.212 36.7372C404.63 40.3477 409.695 45.3624 413.406 51.7811C417.217 58.0996 419.123 65.3708 419.123 73.5948C419.123 81.8188 417.217 89.1402 413.406 95.559C409.595 101.978 404.48 107.043 398.061 110.753C391.643 114.364 384.522 116.169 376.699 116.169ZM376.699 100.373C381.513 100.373 385.775 99.2197 389.486 96.9129C393.197 94.6062 396.106 91.447 398.212 87.4352C400.418 83.3232 401.521 78.7098 401.521 73.5948C401.521 68.4799 400.418 63.9165 398.212 59.9048C396.106 55.8931 393.197 52.7339 389.486 50.4271C385.775 48.1204 381.513 46.967 376.699 46.967C371.985 46.967 367.723 48.1204 363.912 50.4271C360.201 52.7339 357.242 55.8931 355.036 59.9048C352.929 63.9165 351.876 68.4799 351.876 73.5948C351.876 78.7098 352.929 83.3232 355.036 87.4352C357.242 91.447 360.201 94.6062 363.912 96.9129C367.723 99.2197 371.985 100.373 376.699 100.373Z" fill="#141414"/>
<path d="M433.152 114.365V32.9769H449.4V49.3748L447.895 46.9678C449.7 41.6522 452.609 37.8411 456.621 35.5344C460.632 33.1273 465.446 31.9238 471.063 31.9238H476.027V47.4191H468.957C463.34 47.4191 458.777 49.1742 455.267 52.6845C451.857 56.0944 450.152 61.0088 450.152 67.4275V114.365H433.152Z" fill="#141414"/>
<path d="M488.621 114.367V32.9795H574.522C581.643 32.9795 587.711 34.2833 592.725 36.8909C597.84 39.3982 601.752 43.0088 604.46 47.7226C607.268 52.4363 608.672 58.1029 608.672 64.7222V114.367H591.672V65.324C591.672 60.1087 590.118 55.9967 587.009 52.9879C584 49.8789 579.838 48.3243 574.522 48.3243H557.071V114.367H540.222V48.3243H505.621V114.367H488.621Z" fill="#141414"/>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

-125
View File
@@ -1,125 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="Livello_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 282.7 181.9" enable-background="new 0 0 282.7 181.9" xml:space="preserve">
<g>
<g>
<path fill="#1D1D1B" d="M41.9,49.8v-2.2c0-4.5-3.6-7.7-9.7-7.7c-6.8,0-10.7,4.3-10.3,11.1L5.7,49.3c-0.8-15.4,11.4-24,26.1-24
c17.2,0,27,8.9,27,22.8v42.2H42v-6.2c-2.6,4.5-7.9,8.1-15.2,8.1C13.1,92.3,5.5,83,5.5,73.4c0-12,8.1-18.7,24.3-21.4L41.9,49.8z
M41.9,64.8v-2.8l-9.8,1.8c-6.1,1.2-9.6,3.2-9.6,7.5c0,3.6,3.1,6.1,7.7,6.1C37.1,77.3,41.9,72.4,41.9,64.8z"/>
<path fill="#1D1D1B" d="M71.2,0h18.1v90.3H71.2V0z"/>
<path fill="#1D1D1B" d="M146.6,32.8V0h18.1v90.3h-18.1v-5.6c-4.8,5-11,7.5-18.7,7.5c-16.6,0-28.9-14.8-28.9-33.4
c0-18.7,12.3-33.6,28.9-33.6C135.6,25.3,141.8,27.7,146.6,32.8z M146.6,58.9c0-9.4-5.9-16.3-14.7-16.3c-8.8,0-14.8,7-14.8,16.3
c0,9.4,6.1,16.1,14.8,16.1C140.6,75,146.6,68.1,146.6,58.9z"/>
<path fill="#1D1D1B" d="M211.6,49.8v-2.2c0-4.5-3.6-7.7-9.7-7.7c-6.8,0-10.7,4.3-10.3,11.1l-16.1-1.7c-0.8-15.4,11.4-24,26.1-24
c17.2,0,27,8.9,27,22.8v42.2h-16.8v-6.2c-2.6,4.5-7.9,8.1-15.2,8.1c-13.7,0-21.3-9.3-21.3-18.8c0-12,8.1-18.7,24.3-21.4
L211.6,49.8z M211.6,64.8v-2.8l-9.8,1.8c-6.1,1.2-9.5,3.2-9.5,7.5c0,3.6,3.1,6.1,7.7,6.1C206.8,77.3,211.6,72.4,211.6,64.8z"/>
</g>
<g>
<path fill="#FDC608" d="M242.1,9.2l12.4,4.1V0h12v13.3L279,9.2l3.9,11.5l-12.5,4.1l7.7,10.6l-9.7,7.2l-7.9-10.7l-8,10.7l-9.7-7.2
l7.9-10.6l-12.5-4.1L242.1,9.2z"/>
</g>
</g>
<g>
<path fill="#1D1D1B" d="M72.4,155.7v-14.8h10v1.3h-8.6v5.3h7.7v1.3h-7.7v5.6h8.6v1.3H72.4z"/>
<path fill="#1D1D1B" d="M92.8,145.6v10.1h-1.4v-1.4c-0.8,1.1-1.9,1.8-3.5,1.8c-2.2,0-3.5-1.4-3.5-3.8v-6.6h1.4v6.3
c0,1.9,0.8,2.8,2.5,2.8c1.9,0,3.1-1.4,3.1-3.7v-5.4H92.8z"/>
<path fill="#1D1D1B" d="M95.7,155.7v-10.1h1.4v1.2c0.6-1,1.5-1.5,2.7-1.5c1.8,0,3.2,1.4,3.2,3.6l0,0.4l-1.4,0.2c0-0.3,0-0.4,0-0.5
c0-1.5-0.8-2.3-2-2.3c-1.5,0-2.5,1.2-2.5,3.3v5.8H95.7z"/>
<path fill="#1D1D1B" d="M114.6,150.7c0,3-2.1,5.3-5.2,5.3c-3.1,0-5.2-2.3-5.2-5.3c0-3,2.2-5.4,5.2-5.4
C112.5,145.3,114.6,147.6,114.6,150.7z M113.3,150.7c0-2.3-1.6-4.1-3.8-4.1c-2.2,0-3.8,1.8-3.8,4.1c0,2.3,1.6,4,3.8,4
C111.7,154.7,113.3,153,113.3,150.7z"/>
<path fill="#1D1D1B" d="M116.9,160.5v-14.8h1.4v1.7c0.8-1.3,2.2-2,3.9-2c2.8,0,4.8,2.3,4.8,5.3s-2,5.4-4.8,5.4
c-1.7,0-3-0.8-3.9-2.1v6.5H116.9z M125.6,150.6c0-2.3-1.5-4-3.7-4c-2.1,0-3.7,1.7-3.7,4c0,2.3,1.5,4.1,3.7,4.1
C124.1,154.7,125.6,152.9,125.6,150.6z"/>
<path fill="#1D1D1B" d="M138,149.9v0.8h-8.1c0.1,2.4,1.6,4,3.7,4c1.7,0,3-1.1,3.3-2.8l1.4,0.2c-0.3,2.3-2.2,3.8-4.6,3.8
c-3,0-5.1-2.2-5.1-5.3c0-3.1,2-5.4,4.9-5.4C136.2,145.3,138,147.2,138,149.9z M136.6,149.5c0-1.7-1.3-2.9-3.1-2.9
c-1.8,0-3.1,1.2-3.5,2.9H136.6z"/>
<path fill="#1D1D1B" d="M146.8,149.3v-0.5c0-1.3-0.9-2.2-2.6-2.2c-1.7,0-2.6,1-2.6,2.5l0,0.3l-1.4-0.1c-0.1-2.6,1.6-4,3.9-4
c2.6,0,4,1.4,4,3.6v6.8h-1.3v-1.4c-0.7,1.1-1.8,1.7-3.2,1.7c-2.4,0-3.5-1.5-3.5-3c0-1.9,1.3-2.9,3.8-3.3L146.8,149.3z M146.8,151.3
v-0.8l-2.7,0.4c-1.8,0.3-2.7,0.9-2.7,2c0,1.1,0.9,1.8,2.4,1.8C145.6,154.7,146.8,153.4,146.8,151.3z"/>
<path fill="#1D1D1B" d="M151,155.7v-10.1h1.4v1.4c0.8-1.1,1.9-1.8,3.5-1.8c2.2,0,3.5,1.4,3.5,3.8v6.6H158v-6.3
c0-1.9-0.8-2.8-2.5-2.8c-1.9,0-3.1,1.4-3.1,3.7v5.4H151z"/>
<path fill="#1D1D1B" d="M166.6,155.7l6.1-14.8h1.6l6.1,14.8h-1.5l-1.9-4.6h-7l-1.9,4.6H166.6z M170.5,149.8h5.9l-3-7.3L170.5,149.8
z"/>
<path fill="#1D1D1B" d="M182.6,151.7c0.2,1.8,1.4,3,3.1,3c1.6,0,2.6-0.8,2.6-1.9c0-1-0.8-1.6-2.8-1.8c-2.4-0.2-3.6-1.2-3.6-3
c0-1.6,1.5-2.7,3.6-2.7c1.9,0,3.4,1.2,3.8,3.1l-1.3,0.4c-0.2-1.4-1.2-2.2-2.5-2.2c-1.3,0-2.2,0.6-2.2,1.4c0,0.9,0.6,1.5,2.4,1.7
c2.7,0.3,4,1.3,4,3.1c0,1.9-1.6,3.2-4,3.2c-1.2,0-2.2-0.4-3.1-1.1c-0.8-0.7-1.3-1.7-1.5-2.9L182.6,151.7z"/>
<path fill="#1D1D1B" d="M192.4,151.7c0.2,1.8,1.4,3,3.1,3c1.6,0,2.6-0.8,2.6-1.9c0-1-0.8-1.6-2.8-1.8c-2.4-0.2-3.6-1.2-3.6-3
c0-1.6,1.5-2.7,3.6-2.7c1.9,0,3.4,1.2,3.8,3.1l-1.3,0.4c-0.2-1.4-1.2-2.2-2.5-2.2c-1.3,0-2.2,0.6-2.2,1.4c0,0.9,0.6,1.5,2.4,1.7
c2.7,0.3,4,1.3,4,3.1c0,1.9-1.6,3.2-4,3.2c-1.2,0-2.2-0.4-3.1-1.1c-0.8-0.7-1.3-1.7-1.5-2.9L192.4,151.7z"/>
<path fill="#1D1D1B" d="M211.4,150.7c0,3-2.1,5.3-5.2,5.3c-3.1,0-5.2-2.3-5.2-5.3c0-3,2.2-5.4,5.2-5.4
C209.3,145.3,211.4,147.6,211.4,150.7z M210,150.7c0-2.3-1.6-4.1-3.8-4.1c-2.2,0-3.8,1.8-3.8,4.1c0,2.3,1.6,4,3.8,4
C208.4,154.7,210,153,210,150.7z"/>
<path fill="#1D1D1B" d="M222.3,148.7l-1.3,0.4c-0.2-1.5-1.4-2.4-3-2.4c-2.1,0-3.6,1.7-3.6,3.9c0,2.5,1.5,4.2,3.7,4.2
c1.8,0,3.2-1.2,3.4-3l1.3,0.3c-0.3,2.3-2.3,4-4.7,4c-3,0-5.1-2.2-5.1-5.3c0-3.1,2-5.4,4.9-5.4C220.2,145.3,221.8,146.6,222.3,148.7
z"/>
<path fill="#1D1D1B" d="M226.6,142.7c0,0.6-0.5,1.1-1,1.1c-0.6,0-1.1-0.5-1.1-1.1c0-0.6,0.5-1.1,1.1-1.1
C226.1,141.6,226.6,142.1,226.6,142.7z M224.8,155.7v-10.1h1.4v10.1H224.8z"/>
<path fill="#1D1D1B" d="M235.4,149.3v-0.5c0-1.3-0.9-2.2-2.6-2.2c-1.7,0-2.6,1-2.6,2.5l0,0.3l-1.4-0.1c-0.1-2.6,1.6-4,3.9-4
c2.6,0,4,1.4,4,3.6v6.8h-1.3v-1.4c-0.7,1.1-1.8,1.7-3.2,1.7c-2.4,0-3.5-1.5-3.5-3c0-1.9,1.3-2.9,3.8-3.3L235.4,149.3z M235.4,151.3
v-0.8l-2.7,0.4c-1.8,0.3-2.7,0.9-2.7,2c0,1.1,0.9,1.8,2.4,1.8C234.1,154.7,235.4,153.4,235.4,151.3z"/>
<path fill="#1D1D1B" d="M238.5,146.9v-1.3h2v-3.1h1.4v3.1h5.4v1.3H242v5.4c0,1.6,0.8,2.5,2.3,2.5c1.4,0,2.2-1.1,2.1-2.7l1.4,0.1
c0.1,2.4-1.4,3.9-3.4,3.9c-2.3,0-3.7-1.4-3.7-3.8v-5.3H238.5z"/>
<path fill="#1D1D1B" d="M251.6,142.7c0,0.6-0.5,1.1-1,1.1c-0.6,0-1.1-0.5-1.1-1.1c0-0.6,0.5-1.1,1.1-1.1
C251.1,141.6,251.6,142.1,251.6,142.7z M249.9,155.7v-10.1h1.4v10.1H249.9z"/>
<path fill="#1D1D1B" d="M263.8,150.7c0,3-2.1,5.3-5.2,5.3c-3.1,0-5.2-2.3-5.2-5.3c0-3,2.2-5.4,5.2-5.4
C261.7,145.3,263.8,147.6,263.8,150.7z M262.5,150.7c0-2.3-1.6-4.1-3.8-4.1c-2.2,0-3.8,1.8-3.8,4.1c0,2.3,1.6,4,3.8,4
C260.9,154.7,262.5,153,262.5,150.7z"/>
<path fill="#1D1D1B" d="M266.1,155.7v-10.1h1.4v1.4c0.8-1.1,1.9-1.8,3.5-1.8c2.2,0,3.5,1.4,3.5,3.8v6.6h-1.4v-6.3
c0-1.9-0.8-2.8-2.5-2.8c-1.9,0-3.1,1.4-3.1,3.7v5.4H266.1z"/>
</g>
<g>
<path fill="#1D1D1B" d="M71.2,168v-1.3h2v-1.2c0-2.2,1.5-3.9,3.7-3.9c1.9,0,3.3,1.3,3.4,3.1l-1.3,0.3c0-1.3-0.8-2.1-2-2.1
c-1.4,0-2.3,1-2.3,2.6v1.2h5.6v1.3h-5.6v8.8h-1.4V168H71.2z"/>
<path fill="#1D1D1B" d="M90.6,171.8c0,3-2.1,5.3-5.2,5.3c-3.1,0-5.2-2.3-5.2-5.3c0-3,2.2-5.4,5.2-5.4
C88.5,166.4,90.6,168.8,90.6,171.8z M89.3,171.8c0-2.3-1.6-4.1-3.8-4.1c-2.2,0-3.8,1.8-3.8,4.1c0,2.3,1.6,4,3.8,4
C87.7,175.8,89.3,174.2,89.3,171.8z"/>
<path fill="#1D1D1B" d="M92.9,176.8v-10.1h1.4v1.2c0.6-1,1.5-1.5,2.7-1.5c1.8,0,3.2,1.4,3.2,3.6l0,0.4l-1.4,0.2c0-0.3,0-0.4,0-0.5
c0-1.5-0.8-2.3-2-2.3c-1.5,0-2.5,1.2-2.5,3.3v5.8H92.9z"/>
<path fill="#1D1D1B" d="M107.7,176.8V162h1.4v13.5h8.4v1.3H107.7z"/>
<path fill="#1D1D1B" d="M128.7,171.8c0,3-2.1,5.3-5.2,5.3c-3.1,0-5.2-2.3-5.2-5.3c0-3,2.2-5.4,5.2-5.4
C126.6,166.4,128.7,168.8,128.7,171.8z M127.3,171.8c0-2.3-1.6-4.1-3.8-4.1c-2.2,0-3.8,1.8-3.8,4.1c0,2.3,1.6,4,3.8,4
C125.7,175.8,127.3,174.2,127.3,171.8z"/>
<path fill="#1D1D1B" d="M139.6,169.8l-1.3,0.4c-0.2-1.5-1.4-2.4-3-2.4c-2.1,0-3.6,1.7-3.6,3.9c0,2.5,1.5,4.2,3.7,4.2
c1.8,0,3.2-1.2,3.4-3l1.3,0.3c-0.3,2.3-2.3,4-4.7,4c-3,0-5.1-2.2-5.1-5.3c0-3.1,2-5.4,4.9-5.4C137.5,166.4,139.2,167.8,139.6,169.8
z"/>
<path fill="#1D1D1B" d="M148.5,170.4v-0.5c0-1.3-0.9-2.2-2.6-2.2c-1.7,0-2.6,1-2.6,2.5l0,0.3l-1.4-0.1c-0.1-2.6,1.6-4,3.9-4
c2.6,0,4,1.4,4,3.6v6.8h-1.3v-1.4c-0.7,1.1-1.8,1.7-3.2,1.7c-2.4,0-3.5-1.5-3.5-3c0-1.9,1.3-2.9,3.8-3.3L148.5,170.4z M148.5,172.5
v-0.8l-2.7,0.4c-1.8,0.3-2.7,0.9-2.7,2c0,1.1,0.9,1.8,2.4,1.8C147.2,175.9,148.5,174.5,148.5,172.5z"/>
<path fill="#1D1D1B" d="M152.7,176.8V162h1.4v14.8H152.7z"/>
<path fill="#1D1D1B" d="M162.5,176.8V162h4.9c4.1-0.1,7.1,3.3,7.1,7.4c0.1,4.1-3,7.5-7.1,7.4H162.5z M163.9,175.5h3.5
c3.3,0,5.7-2.6,5.7-6c0-3.5-2.4-6.1-5.7-6.1h-3.5V175.5z"/>
<path fill="#1D1D1B" d="M185.7,171.1v0.8h-8.1c0.1,2.4,1.6,4,3.7,4c1.7,0,3-1.1,3.3-2.8l1.4,0.2c-0.3,2.3-2.2,3.8-4.6,3.8
c-3,0-5.1-2.2-5.1-5.3c0-3.1,2-5.4,4.9-5.4C183.9,166.4,185.7,168.4,185.7,171.1z M184.3,170.6c0-1.7-1.3-2.9-3.1-2.9
c-1.8,0-3.1,1.2-3.5,2.9H184.3z"/>
<path fill="#1D1D1B" d="M188.2,176.8v-10.1h1.4v1.3c0.6-1,1.5-1.6,2.8-1.6c1.4,0,2.4,0.7,2.8,1.9c0.6-1.2,1.6-1.9,3-1.9
c1.9,0,3,1.1,3,3.4l0,7h-1.4v-6.7c0-1.8-0.6-2.5-2-2.5c-1.4,0-2.4,1.1-2.5,3.2l0,6H194v-6.7c0-1.8-0.6-2.5-2-2.5
c-1.5,0-2.5,1.2-2.5,3.5v5.6H188.2z"/>
<path fill="#1D1D1B" d="M213.7,171.8c0,3-2.1,5.3-5.2,5.3c-3.1,0-5.2-2.3-5.2-5.3c0-3,2.2-5.4,5.2-5.4
C211.6,166.4,213.7,168.8,213.7,171.8z M212.4,171.8c0-2.3-1.6-4.1-3.8-4.1c-2.2,0-3.8,1.8-3.8,4.1c0,2.3,1.6,4,3.8,4
C210.8,175.8,212.4,174.2,212.4,171.8z"/>
<path fill="#1D1D1B" d="M224.6,169.8l-1.3,0.4c-0.2-1.5-1.4-2.4-3-2.4c-2.1,0-3.6,1.7-3.6,3.9c0,2.5,1.5,4.2,3.7,4.2
c1.8,0,3.2-1.2,3.4-3l1.3,0.3c-0.3,2.3-2.3,4-4.7,4c-3,0-5.1-2.2-5.1-5.3c0-3.1,2-5.4,4.9-5.4C222.5,166.4,224.2,167.8,224.6,169.8
z"/>
<path fill="#1D1D1B" d="M227.2,176.8v-10.1h1.4v1.2c0.6-1,1.5-1.5,2.7-1.5c1.8,0,3.2,1.4,3.2,3.6l0,0.4l-1.4,0.2c0-0.3,0-0.4,0-0.5
c0-1.5-0.8-2.3-2-2.3c-1.5,0-2.5,1.2-2.5,3.3v5.8H227.2z"/>
<path fill="#1D1D1B" d="M242.4,170.4v-0.5c0-1.3-0.9-2.2-2.6-2.2c-1.7,0-2.6,1-2.6,2.5l0,0.3l-1.4-0.1c-0.1-2.6,1.6-4,3.9-4
c2.6,0,4,1.4,4,3.6v6.8h-1.3v-1.4c-0.7,1.1-1.8,1.7-3.2,1.7c-2.4,0-3.5-1.5-3.5-3c0-1.9,1.3-2.9,3.8-3.3L242.4,170.4z M242.4,172.5
v-0.8l-2.7,0.4c-1.8,0.3-2.7,0.9-2.7,2c0,1.1,0.9,1.8,2.4,1.8C241.2,175.9,242.4,174.5,242.4,172.5z"/>
<path fill="#1D1D1B" d="M255.2,169.8l-1.3,0.4c-0.2-1.5-1.4-2.4-3-2.4c-2.1,0-3.6,1.7-3.6,3.9c0,2.5,1.5,4.2,3.7,4.2
c1.8,0,3.2-1.2,3.4-3l1.3,0.3c-0.3,2.3-2.3,4-4.7,4c-3,0-5.1-2.2-5.1-5.3c0-3.1,2-5.4,4.9-5.4C253.2,166.4,254.8,167.8,255.2,169.8
z"/>
<path fill="#1D1D1B" d="M258.3,177.3c-0.1,1.1,0.2,1.9,0.8,2.5c0.6,0.6,1.4,0.9,2.4,0.9c2.1,0,3.6-1.5,3.6-3.9v-1.9
c-0.7,1.1-2,1.8-3.6,1.8c-2.3,0-3.7-1.5-3.7-3.8l0-6.1h1.4v5.7c0,1.9,0.9,2.9,2.7,2.9c1.9,0,3.2-1.5,3.2-3.8v-4.8h1.4v9.9
c0,3-2,5.2-5,5.2c-2.6,0-4.6-1.7-4.5-4.4L258.3,177.3z"/>
</g>
<g>
<g>
<path fill="#FDC608" d="M4,144.3l12.4,4.1v-13.3h12v13.3l12.5-4.1l3.9,11.5l-12.5,4.1l7.7,10.6l-9.7,7.2L22.5,167l-8,10.7
l-9.7-7.2l7.9-10.6l-12.5-4.1L4,144.3z"/>
</g>
<path fill="none" stroke="#1D1D1B" stroke-width="1.0383" stroke-miterlimit="10" d="M271.9,49.1c0,0,24.6,69.2-51.8,73.6
c-76.4,4.4-185.4-36.1-197.6,1.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 200 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 51 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="125" viewBox="0 0 140 125">
<path d="M18 74 C22 38, 44 17, 70 17 C96 17,118 38,122 74" fill="none" stroke="#FF4D9A" stroke-width="8" stroke-linecap="round"/>
<path d="M37 73 C40 51,52 39,70 39 C88 39,100 51,103 73" fill="none" stroke="#FF4D9A" stroke-width="8" stroke-linecap="round"/>
<path d="M55 74 C56 63,62 57,70 57 C78 57,84 63,85 74" fill="none" stroke="#FF4D9A" stroke-width="8" stroke-linecap="round"/>
<text x="70" y="108" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="800" fill="#F2F2F7">ARCHICARE</text>
</svg>

After

Width:  |  Height:  |  Size: 630 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="150" viewBox="0 0 140 150">
<g fill="none" stroke="#2EE6F6" stroke-width="7" stroke-linejoin="round">
<path d="M70 12 L118 36 L70 60 L22 36 Z"/>
<path d="M22 54 L70 78 L118 54"/>
<path d="M22 72 L70 96 L118 72"/>
</g>
<text x="70" y="120" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="13" font-weight="800" fill="#F2F2F7">DISTRIBUTED</text>
<text x="70" y="137" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="13" font-weight="800" fill="#F2F2F7">INFRASTRUCTURES</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from "astro/zod";
import { ActionError, defineAction } from "astro:actions";
import { z } from "zod";
import { bannerRepository } from "../repositories/banners";
+200 -16
View File
@@ -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<keyof z.infer<typeof translationFields>, 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, string | number>) => string;
function parseTranslations(
raw: string | undefined,
t: Translator,
): Record<string, z.infer<typeof translationFields>> {
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<typeof translationFields>,
): Record<string, string> {
const values: Record<string, string> = {};
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<string, z.infer<typeof translationFields>>,
): Promise<void> {
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(),
};
},
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from "astro/zod";
import { ActionError, defineAction } from "astro:actions";
import { z } from "zod";
import { highlightRepository } from "../repositories/highlights";
+15 -1
View File
@@ -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,
};
+134
View File
@@ -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<UserUserUpdationDto> = 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<string, unknown> = {};
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"),
});
}
},
});
+174
View File
@@ -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 };
},
});
+76
View File
@@ -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"),
});
}
},
});
+34
View File
@@ -40,6 +40,40 @@ export async function getSession(cookies: AstroCookies): Promise<Session | undef
}
}
/**
* Retrieve the session with the User re-fetched from the API, so that role checks
* use the roles the API has now and not the ones stored in the cookie at login.
* The cookie is rewritten only when the roles changed.
* @param cookies AstroCookies interface
* @returns The Session data, or `undefined` if the User could not be fetched
*/
export async function getFreshSession(cookies: AstroCookies): Promise<Session | undefined> {
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
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import { t } from "../../i18n/store";
import Send from "../icons/actions/Send.svelte";
let { class: classes = "" } = $props<{
class?: ClassNameValue;
}>();
</script>
<div class={twMerge("flex w-full max-w-340 items-center gap-6 self-center", classes)}>
<textarea
class="text-content bg-grey h-20 w-full resize-none rounded-lg border-0 p-4 text-base shadow-sm ring-0"
placeholder={$t("pages.review.chat.placeholder")}></textarea>
<button
class="border-grey flex aspect-square size-10 shrink-0 cursor-pointer items-center justify-center rounded-[80px] border bg-white p-2 shadow-[0_2px_4px_0_rgba(0,0,0,0.16)]"
><Send /></button
>
</div>
+11 -4
View File
@@ -13,13 +13,19 @@
queryParams = {},
filenamePrefix = "export",
totalItems = undefined,
size = "sm",
disabled = false,
} = $props<{
endpoint?: string;
queryParams?: Record<string, unknown>;
filenamePrefix?: string;
totalItems?: number;
size?: "sm" | "md";
disabled?: boolean;
}>();
const iconSize = $derived(size === "md" ? "24" : "16");
let abortController = $state<AbortController | null>(null);
let isExporting = $state(false);
let exportProgress = $state(0);
@@ -150,21 +156,22 @@
<div class="flex items-center gap-2">
<Button
size="sm"
{size}
kind="secondary"
onclick={handleExportCSV}
disabled={isExporting}
disabled={disabled || isExporting}
class={disabled ? "cursor-not-allowed opacity-50 hover:cursor-not-allowed" : ""}
aria-label={$t("domain.export.csv")}
>
{#if isExporting}
<Spinner width="16px" height="16px" class="text-secondary" />
<Spinner width="{iconSize}px" height="{iconSize}px" class="text-secondary" />
<span class="text-secondary font-bold">
{exportProgress > 0
? `${rowsExported} / ${totalItems} (${exportProgress}%)`
: $t("domain.export.exporting")}
</span>
{:else}
<Download width="16" height="16" class="text-secondary" />
<Download width={iconSize} height={iconSize} class="text-secondary" />
<span class="text-secondary font-bold">
{$t("domain.export.csv")}
</span>
+5
View File
@@ -23,6 +23,11 @@ export const ADMIN_SECTIONS: AdminSection[] = [
labelKey: "pages.admin.nav.projects",
descriptionKey: "pages.admin.projects.description",
},
{
href: "/admin/reviews/projects",
labelKey: "pages.admin.nav.reviews",
descriptionKey: "pages.admin.reviews.description",
},
{
href: "/admin/users",
labelKey: "pages.admin.nav.users",
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from "../../../../i18n/store";
import { apiUsersGetCollection, type User } from "../../../../openapi/client";
import { debounce } from "../../../../utils/debounce";
import { highlightMatch } from "../../../../utils/highlights";
import CloseIcon from "../../../icons/navigation/Close.svelte";
import Spinner from "../../../icons/status/Spinner.svelte";
@@ -22,8 +23,6 @@
let searched = $state(false);
let showCreateForm = $state(false);
let debounce: ReturnType<typeof setTimeout> | undefined;
async function searchUsers() {
const trimmed = form.user.email.trim();
if (!trimmed || !emailRegex.test(trimmed)) {
@@ -52,8 +51,10 @@
}
}
const debouncedSearch = debounce(searchUsers, 400);
function onEmailInput() {
clearTimeout(debounce);
debouncedSearch.cancel();
if (form.existingUser) {
form.existingUser = null;
}
@@ -64,7 +65,7 @@
const trimmed = form.user.email.trim();
if (trimmed.length >= 3 && emailRegex.test(trimmed)) {
debounce = setTimeout(() => searchUsers(), 400);
debouncedSearch();
}
}
@@ -32,7 +32,7 @@
</p>
</div>
<Toast variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<Toast floating variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<div class="flex flex-col gap-6">
<BannersTabs currentTab={currentSubtab} onTabChange={handleTabChange} />
+62 -31
View File
@@ -4,21 +4,33 @@
import { t } from "../../../i18n/store";
import { apiProjectsGetCollection } from "../../../openapi/client/sdk.gen";
import { debounce } from "../../../utils/debounce";
import { toCollectionItems } from "../../../utils/hydra";
import ActionableButton from "../../library/buttons/ActionableButton.svelte";
import Button from "../../library/buttons/Button.svelte";
import DropdownMenu from "../../library/dropdown/DropdownMenu.svelte";
import Toast from "../../library/feedback/Toast.svelte";
import RadioButton from "../../library/inputs/RadioButton.svelte";
import Select from "../../library/inputs/Select.svelte";
import Title from "../../library/typography/Title.svelte";
import type { Project } from "../../../openapi/client/types.gen";
import type { DropdownOption } from "../../library/dropdown/dropdown.types";
interface SlotAssignment {
position: number;
projectId: number;
projectTitle: string;
projectImage: string | null;
}
interface Props {
config: {
highlight: {
type: string;
layout: string;
} | null;
slots: { position: number; projectId: number }[];
slots: SlotAssignment[];
};
}
@@ -62,43 +74,45 @@
let searchOptions = $state<DropdownOption[]>([]);
let selectedOption = $state<DropdownOption[]>([]);
let editingSlotIndex = $state<number | null>(null);
let searchResults: Project[] = [];
let showError = $state(false);
let errorMessage = $state("");
let slotAssignments = $state<{ position: number; projectId: number; projectTitle: string }[]>(
config.slots.map((s) => ({
position: s.position,
projectId: s.projectId,
projectTitle: String(s.projectId),
})),
);
let slotAssignments = $state<SlotAssignment[]>(config.slots);
let slotCount = $derived(LAYOUTS.find((l) => l.id === layout)?.rows.flat().length ?? 0);
let slots = $derived(
Array.from({ length: slotCount }, (_, i) => {
const assignment = slotAssignments.find((a) => a.position === i);
return {
position: i,
projectId: assignment?.projectId ?? null,
projectTitle: assignment?.projectTitle ?? null,
};
}),
Array.from({ length: slotCount }, (_, i) => slotAssignments.find((a) => a.position === i)),
);
async function handleSearch(query: string) {
if (!query || query.length < 2) {
searchOptions = [];
return;
}
const { data } = await apiProjectsGetCollection({
const searchProjects = debounce(async (title: string) => {
const { data, error } = await apiProjectsGetCollection({
baseUrl: "/api/relay",
headers: { Accept: "application/ld+json" },
query: { title: query, page: 1, itemsPerPage: 10 },
query: { title, itemsPerPage: 10 },
});
searchOptions = (data ?? []).map((p) => ({
if (error) console.error("Project search failed:", error);
searchResults = toCollectionItems<Project>(data);
searchOptions = searchResults.map((p) => ({
id: String(p.id),
label: p.title ?? p.slug ?? "",
selected: false,
}));
});
function handleSearch(query: string) {
const trimmed = query.trim();
if (trimmed.length < 2) {
searchProjects.cancel();
searchOptions = [];
return;
}
searchProjects(trimmed);
}
function handleAddProject(slotIndex: number) {
@@ -112,12 +126,14 @@
function handleConfirmAdd() {
if (editingSlotIndex === null || selectedOption.length === 0) return;
const chosen = selectedOption[0];
const project = searchResults.find((p) => String(p.id) === chosen.id);
slotAssignments = [
...slotAssignments.filter((a) => a.position !== editingSlotIndex),
{
position: editingSlotIndex,
projectId: Number(chosen.id),
projectTitle: chosen.label,
projectImage: project?.video?.thumbnail ?? project?.cover ?? null,
},
];
addOpen = false;
@@ -130,9 +146,15 @@
async function handleSave() {
const projectIds = slotAssignments
.filter((a) => a.position < slotCount)
.sort((a, b) => a.position - b.position)
.map((a) => a.projectId);
await actions.saveHighlights({ type, layout, slots: projectIds });
const { error } = await actions.saveHighlights({ type, layout, slots: projectIds });
if (error) {
errorMessage = error.message;
showError = true;
}
}
</script>
@@ -142,9 +164,9 @@
<Title level={2} variant="headline">{$t("pages.admin.home.highlights.title")}</Title>
<p class="text-content text-base">{$t("pages.admin.home.highlights.description")}</p>
</div>
<Button size="sm" class="shrink-0" onclick={handleSave}>
<ActionableButton action={handleSave} autoreset={2000} class="w-fit shrink-0 px-6">
{$t("common.save")}
</Button>
</ActionableButton>
</header>
<section class="flex flex-col gap-4">
@@ -186,7 +208,7 @@
<div class="grid grid-cols-6 gap-2">
{#each row as span}
<div
class="h-10 rounded-lg {span} {selected
class="h-21 rounded-lg {span} {selected
? 'bg-primary'
: 'bg-grey'}"
></div>
@@ -216,7 +238,14 @@
number: String(index + 1).padStart(2, "0"),
})}
</p>
{#if slot.projectId}
{#if slot}
{#if slot.projectImage}
<img
src={slot.projectImage}
alt={slot.projectTitle}
class="aspect-video w-full rounded-lg object-cover"
/>
{/if}
<p class="text-content text-body-small font-medium">
{slot.projectTitle}
</p>
@@ -250,12 +279,14 @@
</section>
</div>
<Toast floating variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<Modal
bind:open={addOpen}
closeBtnClass="top-3 end-3 cursor-pointer bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="fixed top-1/2 left-1/2 mx-2 flex w-full max-w-172 -translate-x-1/2 -translate-y-1/2 flex-col gap-6 divide-y-0 rounded-3xl bg-white p-6 shadow-lg backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px] sm:mx-4 lg:mx-0"
class="fixed top-1/2 left-1/2 mx-2 flex w-full max-w-172 -translate-x-1/2 -translate-y-1/2 flex-col gap-6 divide-y-0 overflow-visible rounded-3xl bg-white p-6 shadow-lg backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px] sm:mx-4 lg:mx-0"
headerClass="md:p-0 p-0 border-none"
bodyClass="md:p-0 p-0 border-none"
bodyClass="md:p-0 p-0 border-none overflow-y-visible"
footerClass="md:p-0 p-0 border-none flex items-center justify-end gap-4"
>
{#snippet header()}
+34 -9
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { actions } from "astro:actions";
import HeroForm from "./HeroForm.svelte";
import HeroHistory from "./HeroHistory.svelte";
import HeroTabs from "./HeroTabs.svelte";
@@ -9,20 +11,43 @@
import type { HomeHeroRecord } from "../../../../repositories/homeHero";
interface Props {
hero?: HomeHeroRecord | null;
heroes: HomeHeroRecord[];
}
let { hero = null, heroes }: Props = $props();
let list = $state(heroes);
let { heroes }: Props = $props();
let currentSubtab = $state("fields");
let showError = $state(false);
let errorMessage = $state("");
let heroesList = $state(heroes);
function handleTabChange(tabId: string) {
currentSubtab = tabId;
// Re-fetch every time the history is opened, so it always shows the
// latest blocks regardless of when they were saved.
if (tabId === "history") {
refreshHeroes();
}
}
// The list is passed in once by the page; after a save it must be refreshed
// so the history shows the updated block without a full page reload.
async function refreshHeroes() {
try {
const { data, error } = await actions.getHomeHeroData();
if (error) {
errorMessage = error.message;
showError = true;
return;
}
heroesList = data?.heroes ?? heroesList;
} catch (e) {
errorMessage = e instanceof Error ? e.message : String(e);
showError = true;
}
}
</script>
@@ -35,20 +60,20 @@
</p>
</div>
<Toast variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<Toast floating variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<div class="flex flex-col gap-6">
<HeroTabs currentTab={currentSubtab} onTabChange={handleTabChange} />
{#if currentSubtab === "fields"}
<HeroForm {hero} />
<HeroForm onSaved={refreshHeroes} />
{:else if currentSubtab === "history"}
<HeroHistory
rows={list}
onDelete={(id) => (list = list.filter((h) => h.id !== id))}
onError={(message) => {
rows={heroesList}
onError={(message: string) => {
errorMessage = message;
showError = true;
}}
onSaved={refreshHeroes}
/>
{/if}
</div>
+467 -97
View File
@@ -1,27 +1,95 @@
<script lang="ts">
import { actions, isInputError } from "astro:actions";
import { Modal } from "flowbite-svelte";
import { locale, t } from "../../../../i18n/store";
import { getDefaultLanguage } from "../../../../utils/consts";
import { formatDate, startOfDay } from "../../../../utils/dates";
import { getLanguageDisplayName } from "../../../../utils/lang";
import { iso639_1Codes } from "../../../../utils/lang.types";
import LanguagesDropdown from "../../../header/LanguagesDropdown.svelte";
import Trash from "../../../icons/actions/Trash.svelte";
import HeroPreviewModal from "./HeroPreviewModal.svelte";
import { locale, t } from "../../../../i18n/store";
import { formatDate, startOfDay } from "../../../../utils/dates";
import Eye from "../../../icons/media/Eye.svelte";
import ActionableButton from "../../../library/buttons/ActionableButton.svelte";
import Button from "../../../library/buttons/Button.svelte";
import DropdownMenu from "../../../library/dropdown/DropdownMenu.svelte";
import type { DropdownOption } from "../../../library/dropdown/dropdown.types";
import Toast from "../../../library/feedback/Toast.svelte";
import DeleteModal from "../../../library/feedback/DeleteModal.svelte";
import DateInput from "../../../library/inputs/DateInput.svelte";
import ImageUploadModal from "../../../library/inputs/ImageUploadModal.svelte";
import RichTextEditor from "../../../library/inputs/RichTextEditor.svelte";
import TextInput from "../../../library/inputs/TextInput.svelte";
import Title from "../../../library/typography/Title.svelte";
import type { HomeHeroRecord } from "../../../../repositories/homeHero";
import type { HeroTranslations, HomeHeroRecord } from "../../../../repositories/homeHero";
import type { UploadedObject } from "../../../../utils/media/objectStorage.types";
interface Props {
hero?: HomeHeroRecord | null;
onSaved?: () => void;
}
let { hero = null }: Props = $props();
let { hero = null, onSaved }: Props = $props();
/** The six text fields that exist per language. */
interface HeroFields {
title: string;
content: string;
primaryCtaText: string;
primaryCtaLink: string;
secondaryCtaText: string;
secondaryCtaLink: string;
}
// The admin may translate into ANY recognized language, not only the locales
// the UI itself is available in. The catalog covers all ISO 639-1 codes the
// browser can name; the search narrows it, unknown codes are not offered.
const ALL_LANGUAGE_OPTIONS: DropdownOption[] = iso639_1Codes
.map((code) => ({ id: code, label: getLanguageDisplayName(code) }))
.filter((option): option is { id: string; label: string } => option.label !== undefined)
.map((option) => ({
id: option.id,
label: `${option.label} (${option.id})`,
selected: false,
}));
function emptyFields(): HeroFields {
return {
title: "",
content: "",
primaryCtaText: "",
primaryCtaLink: "",
secondaryCtaText: "",
secondaryCtaLink: "",
};
}
function fieldsFromRecord(record: HomeHeroRecord): HeroFields {
return {
title: record.title,
content: record.content,
primaryCtaText: record.primaryCtaText ?? "",
primaryCtaLink: record.primaryCtaLink ?? "",
secondaryCtaText: record.secondaryCtaText ?? "",
secondaryCtaLink: record.secondaryCtaLink ?? "",
};
}
function fieldsFromTranslations(
overrides: NonNullable<HomeHeroRecord["translations"]>[string] | undefined,
): HeroFields {
return {
title: overrides?.title ?? "",
content: overrides?.content ?? "",
primaryCtaText: overrides?.primaryCtaText ?? "",
primaryCtaLink: overrides?.primaryCtaLink ?? "",
secondaryCtaText: overrides?.secondaryCtaText ?? "",
secondaryCtaLink: overrides?.secondaryCtaLink ?? "",
};
}
type HeroMedia = Pick<UploadedObject, "url" | "type" | "name">;
@@ -39,7 +107,49 @@
let formElement: HTMLFormElement;
const contentId = $props.id();
let content = $state(hero?.content ?? "");
// All languages are equal: there is no "base" language, only a default one
// (the platform language) used to seed a blank block. An existing block is
// opened with every language it is available in, prefilled from its stored
// translations, and the default can be removed like any other language
// once a second one exists.
const defaultLanguage = getDefaultLanguage();
const baseLanguage = hero?.language || defaultLanguage;
const languagesSeed = hero
? [
...new Set([
baseLanguage,
...(hero.languages ?? []).filter((code) => code !== baseLanguage),
]),
]
: [defaultLanguage];
const initialFieldValues: Record<string, HeroFields> = {};
for (const lang of languagesSeed) {
initialFieldValues[lang] =
lang === baseLanguage && hero
? fieldsFromRecord(hero)
: fieldsFromTranslations(hero?.translations?.[lang]);
}
let languages = $state<string[]>(languagesSeed);
let selectedLanguage = $state<string>(languagesSeed[0]);
let isAddModalOpen = $state(false);
let languageSearch = $state("");
let candidateOptions = $state<DropdownOption[]>([]);
let selectedLanguageOption = $state<DropdownOption[]>([]);
let isRemoveModalOpen = $state(false);
let fieldValues = $state<Record<string, HeroFields>>(initialFieldValues);
// The stored row must be authored in exactly one language. The platform
// language keeps that role while it is present; otherwise the first chosen
// language becomes the authoring language.
const primaryLanguage = $derived(
languages.includes(defaultLanguage) ? defaultLanguage : (languages[0] ?? defaultLanguage),
);
let fieldErrors: FieldErrors = $state({});
let errorMessage = $state("");
@@ -69,22 +179,119 @@
};
}
function openAddModal() {
languageSearch = "";
selectedLanguageOption = [];
refreshLanguageOptions();
isAddModalOpen = true;
}
function refreshLanguageOptions(query = "") {
// After picking an option its label is shown in the search input; that
// label must not act as a filter, otherwise reopening the list would
// narrow it to the selected language alone.
const search = query.trim().toLowerCase();
const selectedLabel = ALL_LANGUAGE_OPTIONS.find(
(option) => option.id === selectedLanguageOption[0]?.id,
)?.label.toLowerCase();
const active = search && search !== selectedLabel ? search : "";
candidateOptions = ALL_LANGUAGE_OPTIONS.filter(
(option) =>
!languages.includes(option.id) &&
(!active ||
option.label.toLowerCase().includes(active) ||
option.id.includes(active)),
).map((option) => ({
...option,
selected: selectedLanguageOption[0]?.id === option.id,
}));
}
function handleAddLanguageChange(option: DropdownOption) {
selectedLanguageOption = [option];
languageSearch = option.label;
refreshLanguageOptions(option.label);
}
function confirmAddLanguage() {
const lang = selectedLanguageOption[0]?.id;
if (!lang || languages.includes(lang)) {
return;
}
if (!fieldValues[lang]) {
fieldValues[lang] = emptyFields();
}
languages = [...languages, lang];
selectedLanguage = lang;
languageSearch = "";
selectedLanguageOption = [];
isAddModalOpen = false;
}
function openRemoveModal() {
// Any language can be removed, but at least one must remain.
if (languages.length > 1) {
isRemoveModalOpen = true;
}
}
function confirmRemoveLanguage() {
delete fieldValues[selectedLanguage];
languages = languages.filter((lang) => lang !== selectedLanguage);
selectedLanguage = languages[0] ?? defaultLanguage;
isRemoveModalOpen = false;
}
const currentFields = $derived(fieldValues[selectedLanguage] ?? fieldValues[primaryLanguage]);
function openPreview() {
const data = new FormData(formElement);
const field = (name: FieldName) => String(data.get(name) ?? "").trim() || null;
// The preview carries every selected language so the modal can switch
// between them; languages other than the one being edited are stored as
// translations, mirroring how a persisted block looks.
const translations: HeroTranslations = {};
for (const lang of languages) {
if (lang === selectedLanguage) {
continue;
}
const fields = fieldValues[lang];
if (!fields) {
continue;
}
translations[lang] = {
title: fields.title || undefined,
content: fields.content || undefined,
primaryCtaText: fields.primaryCtaText.trim() || undefined,
primaryCtaLink: fields.primaryCtaLink.trim() || undefined,
secondaryCtaText: fields.secondaryCtaText.trim() || undefined,
secondaryCtaLink: fields.secondaryCtaLink.trim() || undefined,
};
}
const previewTranslations = Object.keys(translations).length > 0 ? translations : undefined;
previewHero = {
id: 0,
title: field("title") ?? "",
content,
primaryCtaText: field("primaryCtaText"),
primaryCtaLink: field("primaryCtaLink"),
secondaryCtaText: field("secondaryCtaText"),
secondaryCtaLink: field("secondaryCtaLink"),
id: hero?.id ?? 0,
language: selectedLanguage,
title: currentFields.title.trim(),
content: currentFields.content,
primaryCtaText: currentFields.primaryCtaText.trim() || null,
primaryCtaLink: currentFields.primaryCtaLink.trim() || null,
secondaryCtaText: currentFields.secondaryCtaText.trim() || null,
secondaryCtaLink: currentFields.secondaryCtaLink.trim() || null,
mediaUrl: media?.url ?? null,
mediaType: media?.type ?? null,
startsAt,
dateCreated: new Date(),
languages: languages,
translations: previewTranslations,
};
isPreviewOpen = true;
}
@@ -93,49 +300,129 @@
media = files[0];
}
function validate(data: FormData): boolean {
function validate(): boolean {
const errors: FieldErrors = {};
const missingLanguages = new Set<string>();
if (!String(data.get("title") ?? "").trim()) {
errors.title = "system.constraint.text.notEmpty";
}
for (const lang of languages) {
const fields = fieldValues[lang];
if (!String(data.get("content") ?? "").trim()) {
errors.content = "system.constraint.text.notEmpty";
if (!fields?.title.trim() || !fields?.content.trim()) {
missingLanguages.add(lang);
}
if (lang === selectedLanguage) {
if (!fields?.title.trim()) {
errors.title = "system.constraint.text.notEmpty";
}
if (!fields?.content.trim()) {
errors.content = "system.constraint.text.notEmpty";
}
}
}
fieldErrors = errors;
return Object.keys(errors).length === 0;
if (missingLanguages.size > 0) {
errorMessage = $t("pages.admin.home.hero.errors.incompleteLanguages", {
languages: [...missingLanguages]
.map((code) => getLanguageDisplayName(code) ?? code)
.join(", "),
});
showError = true;
return false;
}
return true;
}
async function submit() {
fieldErrors = {};
const data = new FormData(formElement);
if (!validate(data)) {
if (!validate()) {
return;
}
const { error } = await actions.createHomeHero(data);
const data = new FormData(formElement);
if (error) {
if (!isInputError(error)) {
errorMessage = error.message;
showError = true;
const primary = fieldValues[primaryLanguage] ?? emptyFields();
data.set("language", primaryLanguage);
data.set("title", primary.title.trim());
data.set("content", primary.content.trim());
data.set("primaryCtaText", primary.primaryCtaText.trim());
data.set("primaryCtaLink", primary.primaryCtaLink.trim());
data.set("secondaryCtaText", primary.secondaryCtaText.trim());
data.set("secondaryCtaLink", primary.secondaryCtaLink.trim());
const translations: Record<string, HeroFields> = {};
for (const lang of languages) {
if (lang === primaryLanguage) {
continue;
}
const fields = fieldValues[lang];
if (!fields) {
continue;
}
translations[lang] = {
title: fields.title.trim(),
content: fields.content.trim(),
primaryCtaText: fields.primaryCtaText.trim(),
primaryCtaLink: fields.primaryCtaLink.trim(),
secondaryCtaText: fields.secondaryCtaText.trim(),
secondaryCtaLink: fields.secondaryCtaLink.trim(),
};
}
if (Object.keys(translations).length > 0) {
data.set("translations", JSON.stringify(translations));
}
if (typeof hero?.id === "number") {
data.set("id", String(hero.id));
}
try {
const { error } =
typeof hero?.id === "number"
? await actions.updateHomeHero(data)
: await actions.createHomeHero(data);
if (error) {
if (!isInputError(error)) {
errorMessage = error.message;
showError = true;
return;
}
fieldErrors = Object.fromEntries(
Object.entries(error.fields).map(([field, issues]) => [field, issues?.[0]]),
) as FieldErrors;
return;
}
fieldErrors = Object.fromEntries(
Object.entries(error.fields).map(([field, issues]) => [field, issues?.[0]]),
) as FieldErrors;
} catch (e) {
// The action call itself threw (e.g. a network failure); do not
// let ActionableButton swallow it silently.
errorMessage = e instanceof Error ? e.message : String(e);
showError = true;
return;
}
location.reload();
try {
onSaved?.();
} catch (e) {
// Refreshing the history must not be reported as a save failure.
console.error(e);
}
}
async function handleSubmit(event: SubmitEvent) {
@@ -144,74 +431,103 @@
}
</script>
{#snippet textFields(fields: HeroFields)}
<div class="space-y-4">
<TextInput
value={fields.title}
onInput={(value) => (fields.title = String(value))}
required
placeholder={$t("pages.admin.home.hero.fields.titlePlaceholder")}
error={fieldErrors.title && $t(fieldErrors.title)}
/>
<RichTextEditor
id={`${contentId}-${selectedLanguage}`}
value={fields.content}
onChange={(value) => (fields.content = value)}
format="markdown"
showAlignment={false}
placeholder={$t("pages.admin.home.hero.fields.contentPlaceholder")}
error={fieldErrors.content && $t(fieldErrors.content)}
/>
<div class="flex flex-col gap-6 sm:flex-row">
<div class="flex-1">
<TextInput
value={fields.primaryCtaText}
onInput={(value) => (fields.primaryCtaText = String(value))}
placeholder={$t("pages.admin.home.hero.fields.primaryCtaPlaceholder")}
error={fieldErrors.primaryCtaText && $t(fieldErrors.primaryCtaText)}
/>
</div>
<div class="flex-1">
<TextInput
value={fields.primaryCtaLink}
onInput={(value) => (fields.primaryCtaLink = String(value))}
placeholder={$t("pages.admin.home.hero.fields.urlPlaceholder")}
error={fieldErrors.primaryCtaLink && $t(fieldErrors.primaryCtaLink)}
/>
</div>
</div>
<div class="flex flex-col gap-6 sm:flex-row">
<div class="flex-1">
<TextInput
value={fields.secondaryCtaText}
onInput={(value) => (fields.secondaryCtaText = String(value))}
placeholder={$t("pages.admin.home.hero.fields.secondaryCtaPlaceholder")}
error={fieldErrors.secondaryCtaText && $t(fieldErrors.secondaryCtaText)}
/>
</div>
<div class="flex-1">
<TextInput
value={fields.secondaryCtaLink}
onInput={(value) => (fields.secondaryCtaLink = String(value))}
placeholder={$t("pages.admin.home.hero.fields.urlPlaceholder")}
error={fieldErrors.secondaryCtaLink && $t(fieldErrors.secondaryCtaLink)}
/>
</div>
</div>
</div>
{/snippet}
<form bind:this={formElement} onsubmit={handleSubmit} class="flex max-w-167 flex-col gap-10">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-4">
<LanguagesDropdown
{languages}
selected={selectedLanguage}
onSelect={(lang) => (selectedLanguage = lang)}
/>
<Button kind="secondary" size="sm" class="w-fit" onclick={openAddModal}>
{$t("pages.admin.home.hero.addTranslation")}
</Button>
{#if languages.length > 1}
<Button
kind="ghost"
size="sm"
class="text-tertiary w-fit"
onclick={openRemoveModal}
>
<Trash class="size-3 text-current" />
{$t("pages.admin.home.hero.removeTranslation")}
</Button>
{/if}
</div>
</div>
<div class="flex flex-col gap-6">
<Title level={3} variant="subsection">
{$t("pages.admin.home.hero.fields.textsTitle")}
</Title>
<div class="space-y-4">
<TextInput
name="title"
value={hero?.title ?? ""}
required={true}
placeholder={$t("pages.admin.home.hero.fields.titlePlaceholder")}
error={fieldErrors.title && $t(fieldErrors.title)}
/>
<RichTextEditor
id={contentId}
value={content}
onChange={(value) => (content = value)}
format="markdown"
showFontSize={false}
showAlignment={false}
showImage={false}
placeholder={$t("pages.admin.home.hero.fields.contentPlaceholder")}
error={fieldErrors.content && $t(fieldErrors.content)}
/>
<input type="hidden" name="content" value={content.trim()} />
<div class="flex flex-col gap-6 sm:flex-row">
<div class="flex-1">
<TextInput
name="primaryCtaText"
value={hero?.primaryCtaText ?? ""}
placeholder={$t("pages.admin.home.hero.fields.primaryCtaPlaceholder")}
error={fieldErrors.primaryCtaText && $t(fieldErrors.primaryCtaText)}
/>
</div>
<div class="flex-1">
<TextInput
name="primaryCtaLink"
value={hero?.primaryCtaLink ?? ""}
placeholder={$t("pages.admin.home.hero.fields.urlPlaceholder")}
error={fieldErrors.primaryCtaLink && $t(fieldErrors.primaryCtaLink)}
/>
</div>
</div>
<div class="flex flex-col gap-6 sm:flex-row">
<div class="flex-1">
<TextInput
name="secondaryCtaText"
value={hero?.secondaryCtaText ?? ""}
placeholder={$t("pages.admin.home.hero.fields.secondaryCtaPlaceholder")}
error={fieldErrors.secondaryCtaText && $t(fieldErrors.secondaryCtaText)}
/>
</div>
<div class="flex-1">
<TextInput
name="secondaryCtaLink"
value={hero?.secondaryCtaLink ?? ""}
placeholder={$t("pages.admin.home.hero.fields.urlPlaceholder")}
error={fieldErrors.secondaryCtaLink && $t(fieldErrors.secondaryCtaLink)}
/>
</div>
</div>
</div>
{#key selectedLanguage}
{@render textFields(fieldValues[selectedLanguage] ?? fieldValues[primaryLanguage])}
{/key}
</div>
<div class="flex flex-col gap-6">
@@ -302,7 +618,61 @@
<HeroPreviewModal bind:open={isPreviewOpen} hero={previewHero} />
<Toast variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<Modal
bind:open={isAddModalOpen}
closeBtnClass="top-3 end-3 cursor-pointer bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="backdrop:bg-overlay fixed top-1/2 left-1/2 mx-2 flex w-full max-w-178 -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-visible rounded-3xl border-b-0 bg-white p-6 shadow-lg backdrop:backdrop-blur-[5px] sm:mx-4 lg:mx-0"
headerClass="border-b-0 md:p-0 p-0"
bodyClass="md:p-0 p-0 overflow-y-visible border-b-0"
footerClass="md:p-0 p-0 flex items-center justify-end gap-4 border-b-0"
>
{#snippet header()}
<Title level={2} variant="subsection">
{$t("pages.admin.home.hero.addTranslationModal.title")}
</Title>
{/snippet}
<div class="flex flex-col gap-6">
<p class="text-content text-base font-normal">
{$t("pages.admin.home.hero.addTranslationModal.description")}
</p>
<DropdownMenu
variant="basic"
singleSelect
hasSearch
bind:searchValue={languageSearch}
bind:options={candidateOptions}
bind:selected={selectedLanguageOption}
onSearch={(query) => refreshLanguageOptions(query)}
onChange={handleAddLanguageChange}
searchPlaceholder={$t("pages.admin.home.hero.addTranslationModal.searchPlaceholder")}
itemClass="text-start"
/>
</div>
{#snippet footer()}
<Button kind="ghost" onclick={() => (isAddModalOpen = false)} class="w-fit">
{$t("common.cancel")}
</Button>
<Button
onclick={confirmAddLanguage}
class="w-fit"
disabled={selectedLanguageOption.length === 0}
>
{$t("pages.admin.home.hero.addTranslationModal.submit")}
</Button>
{/snippet}
</Modal>
<DeleteModal
bind:open={isRemoveModalOpen}
title={$t("pages.admin.home.hero.removeTranslationModal.title")}
description={$t("pages.admin.home.hero.removeTranslationModal.description")}
onclick={confirmRemoveLanguage}
/>
<Toast floating variant="error" bind:showToast={showError}>{errorMessage}</Toast>
<ImageUploadModal
bind:open={isUploadOpen}
+138 -42
View File
@@ -1,17 +1,20 @@
<script lang="ts">
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<Status, string> = {
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<HomeHeroRecord | null>(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<HomeHeroRecord | null>(null);
function openPreview(row: HomeHeroRecord) {
previewRow = row;
isPreviewOpen = true;
}
let isEditModalOpen = $state(false);
let rowToEdit = $state<HomeHeroRecord | null>(null);
function openEditModal(row: HomeHeroRecord) {
rowToEdit = row;
isEditModalOpen = true;
}
function closeEditModal() {
rowToEdit = null;
isEditModalOpen = false;
}
</script>
<div class="flex flex-col gap-4">
@@ -115,16 +171,25 @@
<p class="text-content">{$t("pages.admin.home.hero.history.description")}</p>
</div>
<ToggleSwitch
bind:value={filter}
class="w-fit"
btnClass="px-6 py-1.5 text-sm whitespace-nowrap"
options={[
{ value: "all", label: $t("pages.admin.home.hero.history.filter.all") },
{ value: "upcoming", label: $t("pages.admin.home.hero.history.filter.upcoming") },
]}
onchange={handleFilterChange}
/>
<div class="flex flex-wrap items-center justify-between gap-4">
<ToggleSwitch
bind:value={filter}
class="w-fit"
btnClass="px-6 py-1.5 text-sm whitespace-nowrap"
options={[
{ value: "all", label: $t("pages.admin.home.hero.history.filter.all") },
{ value: "upcoming", label: $t("pages.admin.home.hero.history.filter.upcoming") },
]}
onchange={handleFilterChange}
/>
<Search
bind:value={searchQuery}
oninput={handleSearch}
class="sm:max-w-80"
placeholder={$t("pages.admin.home.hero.history.searchPlaceholder")}
/>
</div>
<DataTable
{headers}
@@ -135,35 +200,49 @@
totalItems={filtered.length}
{itemsPerPage}
onPageChange={(page) => (currentPage = page)}
onRowClick={openPreview}
onRowClick={openEditModal}
>
{#snippet children(row)}
<TableBodyCell
class="border-variant1 max-w-80 truncate rounded-l-md border-t border-b border-l p-4"
>
<TableBodyCell class="border-variant1 w-16 rounded-l-md border-t border-b border-l p-4">
{row.id}
</TableBodyCell>
<TableBodyCell class="border-variant1 max-w-80 truncate border-t border-b p-4">
{row.title}
</TableBodyCell>
<TableBodyCell class="border-variant1 max-w-80 truncate border-t border-b p-4">
{row.content}
</TableBodyCell>
<TableBodyCell class="border-variant1 border-t border-b p-4">
{formatDate(row.startsAt, $locale)}
{row.startsAt.getTime() === 0 ? "—" : formatDate(row.startsAt, $locale)}
</TableBodyCell>
<TableBodyCell class="border-variant1 border-t border-b p-4">
{(row.languages ?? [])
.map((code: string) => getLanguageDisplayName(code) ?? code)
.join(", ")}
</TableBodyCell>
<TableBodyCell class="border-variant1 border-t border-b p-4">
{@const status = statusOf(row)}
<Tag
class="w-fit"
variant={status === "active"
? "success"
: status === "upcoming"
? "warning"
: undefined}
<span
class="flex w-fit items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold whitespace-nowrap {STATUS_BADGES[
status
]}"
>
<span class="size-1.5 rounded-full bg-current"></span>
{$t(`pages.admin.home.hero.history.status.${status}`)}
</Tag>
</span>
</TableBodyCell>
<TableBodyCell class="border-variant1 w-24 rounded-r-md border-t border-r border-b p-4">
<div class="flex items-center gap-3">
<TableBodyCell class="border-variant1 w-36 rounded-r-md border-t border-r border-b p-4">
<div class="flex items-center justify-end gap-3">
<button
class="text-secondary cursor-pointer transition-transform duration-200 hover:scale-110"
aria-label={$t("common.edit")}
onclick={(event) => {
event.stopPropagation();
openEditModal(row);
}}
>
<Edit class="size-5" />
</button>
<button
class="text-secondary cursor-pointer transition-transform duration-200 hover:scale-110"
aria-label={$t("common.preview")}
@@ -189,7 +268,24 @@
{/snippet}
</DataTable>
<HeroPreviewModal bind:open={isPreviewOpen} hero={heroToPreview} />
<HeroPreviewModal bind:open={isPreviewOpen} hero={previewRow} />
<Modal
bind:open={isEditModalOpen}
closeBtnClass="top-7 end-7 cursor-pointer bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="backdrop:bg-overlay fixed top-1/2 left-1/2 mx-2 flex w-fit max-w-[min(46rem,calc(100vw-1rem))] min-w-152 -translate-x-1/2 -translate-y-1/2 divide-y-0 rounded-3xl bg-white shadow-lg backdrop:backdrop-blur-[5px] sm:mx-4 lg:mx-0"
bodyClass="border-grey max-h-[90vh] overflow-y-auto rounded-t-3xl border-b bg-white p-6"
>
{#if rowToEdit}
<HeroForm
hero={rowToEdit}
onSaved={() => {
closeEditModal();
onSaved?.();
}}
/>
{/if}
</Modal>
<DeleteModal
bind:open={isDeleteModalOpen}
@@ -3,6 +3,8 @@
import { renderMarkdown } from "../../../../utils/renderMarkdown";
import Hero from "../../../hero/Hero.svelte";
import LanguagesDropdown from "../../../header/LanguagesDropdown.svelte";
import { getLanguageDisplayName } from "../../../../utils/lang";
import type { HomeHeroRecord } from "../../../../repositories/homeHero";
@@ -12,6 +14,44 @@
}
let { open = $bindable(false), hero }: Props = $props();
const availableLanguages = $derived(hero?.languages ?? (hero?.language ? [hero.language] : []));
let previewLanguage = $state("");
// Until the effect below picks the block's base language, the editor wants
// to switch with. previewLanguage is "", so fall back to a valid code to
// avoid passing an empty tag to Intl.DisplayNames while rendering.
const resolvedLanguage = $derived(
previewLanguage || hero?.language || availableLanguages[0] || "",
);
// Reset to the base language whenever another block is opened.
$effect(() => {
if (hero) {
previewLanguage = hero.language || availableLanguages[0] || "";
}
});
const previewHero = $derived.by(() => {
if (!hero) {
return null;
}
const language = resolvedLanguage;
const overrides = hero.translations?.[language] ?? {};
return {
...hero,
language,
title: overrides.title || hero.title,
content: overrides.content || hero.content,
primaryCtaText: overrides.primaryCtaText ?? hero.primaryCtaText,
primaryCtaLink: overrides.primaryCtaLink ?? hero.primaryCtaLink,
secondaryCtaText: overrides.secondaryCtaText ?? hero.secondaryCtaText,
secondaryCtaLink: overrides.secondaryCtaLink ?? hero.secondaryCtaLink,
};
});
</script>
<Modal
@@ -21,8 +61,30 @@
bodyClass="p-0"
>
{#if hero}
{#await renderMarkdown(hero.content) then html}
<Hero {hero} content={html} />
{/await}
<div class="flex max-h-[90vh] flex-col">
<div
class="flex items-center justify-end gap-4 border-b border-gray-100 p-4 sm:max-w-[calc(100%-7rem)]"
>
{#if availableLanguages.length > 1}
<LanguagesDropdown
languages={availableLanguages}
selected={resolvedLanguage}
onSelect={(lang) => (previewLanguage = lang)}
/>
{:else}
<span class="text-content text-base font-normal">
{getLanguageDisplayName(availableLanguages[0]) ?? availableLanguages[0]}
</span>
{/if}
</div>
<div class="overflow-y-auto">
{#if previewHero}
{#await renderMarkdown(previewHero.content) then html}
<Hero hero={previewHero} content={html} />
{/await}
{/if}
</div>
</div>
{/if}
</Modal>
@@ -0,0 +1,181 @@
<script lang="ts">
import ReviewProjectsTable, {
type ProjectStatus,
type ReviewProjectRow,
} from "./ReviewProjectsTable.svelte";
import { t } from "../../../i18n/store";
import {
apiProjectsGetCollection,
apiProjectsGetCollectionUrl,
apiProjectsIdOrSlugGetUrl,
apiProjectsIdPatch,
apiProjectSupportsmoneyTotalGetCollection,
apiUsersIdOrHandleGet,
type Project,
type User,
} from "../../../openapi/client/index.ts";
import { useAdminTableState } from "../../../utils/adminTableState.svelte";
import { formatCurrency } from "../../../utils/currencies";
import { extractId } from "../../../utils/extractId";
import { getCollectionTotalItems, toCollectionItems } from "../../../utils/hydra";
import { CAMPAIGN_REVIEW_STATUSES } from "../../../utils/projectStatus";
import Dashboard from "../AdminDashboard.svelte";
import { projectSortMap, type ProjectSortKey } from "../projects/ProjectsTable.svelte";
import type { ApiProjectsGetCollectionData } from "../../../openapi/client/types.gen";
type ProjectsQuery = Partial<ApiProjectsGetCollectionData["query"]>;
const table = useAdminTableState<ProjectSortKey>("date-desc", "review-projects-items-per-page");
let filters: ProjectsQuery = $state({});
let rows = $state<ReviewProjectRow[]>([]);
let totalEarned = $state("—");
let baseQuery = $derived<ProjectsQuery>({ "status[]": CAMPAIGN_REVIEW_STATUSES, ...filters });
let slides = $derived([
{ title: $t("pages.admin.reviews.projects.totalizers.selected"), amount: table.totalItems },
{ title: $t("pages.admin.projects.totalizers.totalEarned"), amount: totalEarned },
]);
async function fetchTotalEarned(ids: number[]) {
const { data } = await apiProjectSupportsmoneyTotalGetCollection({
baseUrl: "/api/relay",
query: {
"project[]": ids.map((id) =>
apiProjectsIdOrSlugGetUrl.replace("{idOrSlug}", String(id)),
),
},
});
if (data) totalEarned = formatCurrency(data.amount, data.currency);
}
async function fetchOwner(iri: string): Promise<User | undefined> {
const idOrHandle = extractId(iri);
if (!idOrHandle) return undefined;
const { data } = await apiUsersIdOrHandleGet({
baseUrl: "/api/relay",
path: { idOrHandle },
});
return data;
}
function toRow(project: Project, owner?: User): ReviewProjectRow {
const min = project.budget?.minimum?.money;
const opt = project.budget?.optimum?.money;
return {
id: project.id ?? 0,
name: project.title,
slug: project.slug ?? String(project.id),
promoter: owner?.displayName ?? owner?.email ?? "—",
promoterHandle: owner?.handle ?? "",
email: owner?.email ?? "",
status: project.status ?? "to_campaign_review",
dateCreated: project.dateCreated ?? "",
dateUpdated: project.dateUpdated ?? "",
dateRelease: project.calendar?.release ?? "",
minOptim:
min && opt
? `${formatCurrency(min.amount, min.currency)} - ${formatCurrency(opt.amount, opt.currency)}`
: "—",
};
}
async function loadProjects(): Promise<void> {
table.isLoading = true;
try {
const sort = projectSortMap[table.selectedSort];
const { data, error } = await apiProjectsGetCollection({
baseUrl: "/api/relay",
query: {
...baseQuery,
page: table.currentPage,
itemsPerPage: table.itemsPerPage,
[`order[${sort.field}]`]: sort.direction,
},
headers: { Accept: "application/ld+json", "Accept-Language": " " },
});
if (error) {
console.error("Failed to fetch review projects:", error);
return;
}
const projects = toCollectionItems<Project>(data);
table.totalItems = getCollectionTotalItems(data);
fetchTotalEarned(projects.map((p) => p.id).filter(Boolean) as number[]);
const ownerIris = [
...new Set(projects.map((p) => p.owner).filter(Boolean)),
] as string[];
const owners = new Map(
await Promise.all(
ownerIris.map(async (iri) => [iri, await fetchOwner(iri)] as const),
),
);
rows = projects.map((p) => toRow(p, p.owner ? owners.get(p.owner) : undefined));
} finally {
table.isLoading = false;
}
}
$effect(() => {
loadProjects();
});
async function handleStatusChange(projectId: number, status: ProjectStatus): Promise<void> {
const { error } = await apiProjectsIdPatch({
path: { id: String(projectId) },
body: { status },
});
if (error) console.error("Failed to update project status:", error);
}
function applyFilters(newFilters: ProjectsQuery): void {
filters = { ...filters, ...newFilters };
table.currentPage = 1;
}
</script>
<Dashboard
title={$t("pages.admin.reviews.projects.title")}
description={$t("pages.admin.reviews.projects.description")}
filters={{
resource: "projects",
filters,
onApplyFilters: applyFilters,
searchPlaceholder: $t("pages.admin.projects.filters.search.placeholder"),
onSelectProject: (p: Project) => applyFilters({ title: p.title }),
}}
filterTags={{
title: $t("pages.admin.reviews.projects.list"),
filters,
onCloseFilter: (newFilters: ProjectsQuery) => {
filters = { ...newFilters };
table.currentPage = 1;
},
resource: "projects",
}}
csv={{
endpoint: apiProjectsGetCollectionUrl,
filenamePrefix: "review-projects",
queryParams: baseQuery,
totalItems: table.totalItems,
}}
slider={{ slides, isLoading: table.isLoading }}
>
<ReviewProjectsTable
projects={rows}
currentPage={table.currentPage}
itemsPerPage={table.itemsPerPage}
selectedSort={table.selectedSort}
totalItems={table.totalItems}
isLoading={table.isLoading}
onPageChange={table.handlePageChange}
onItemsPerPageChange={table.handleItemsPerPageChange}
onSortChange={table.handleSortChange}
onStatusChange={handleStatusChange}
/>
</Dashboard>
@@ -0,0 +1,201 @@
<script lang="ts" module>
import type { Project } from "../../../openapi/client/index.ts";
import type { DataTableHeader } from "../../library/tables/DataTable.svelte";
export type ProjectStatus = NonNullable<Project["status"]>;
export type ReviewProjectRow = {
id: number;
name: string;
slug: string;
promoter: string;
promoterHandle: string;
email: string;
status: ProjectStatus;
dateCreated: string;
dateUpdated: string;
dateRelease: string;
minOptim: string;
};
const headers: DataTableHeader[] = [
{ key: "pages.admin.reviews.projects.table.headers.name" },
{ key: "pages.admin.reviews.projects.table.headers.promoter" },
{ key: "pages.admin.reviews.projects.table.headers.dateSubmitted" },
{ key: "pages.admin.reviews.projects.table.headers.status" },
{ key: "pages.admin.reviews.projects.table.headers.advisor" },
{ key: "", class: "w-12" },
];
</script>
<script lang="ts">
import { TableBodyCell } from "flowbite-svelte";
import { t, locale } from "../../../i18n/store";
import { ADMIN_ITEMS_PER_PAGE_OPTIONS } from "../../../utils/adminTable";
import { formatDate } from "../../../utils/dates";
import { CAMPAIGN_REVIEW_STATUSES } from "../../../utils/projectStatus";
import Comments from "../../icons/Comments.svelte";
import Chevron from "../../icons/navigation/Chevron.svelte";
import Button from "../../library/buttons/Button.svelte";
import DataTable from "../../library/tables/DataTable.svelte";
import DetailsRow, { type DetailsField } from "../DetailsRow.svelte";
import {
PROJECT_ITEMS_PER_PAGE_LABEL,
projectSortOptions,
type ProjectSortKey,
} from "../projects/ProjectsTable.svelte";
interface Props {
projects: ReviewProjectRow[];
currentPage: number;
totalItems: number;
itemsPerPage: number;
isLoading: boolean;
selectedSort: ProjectSortKey;
onPageChange?: (page: number) => void;
onItemsPerPageChange?: (perPage: number) => void;
onSortChange?: (sort: ProjectSortKey) => void;
onStatusChange?: (projectId: number, status: ProjectStatus) => void;
}
let {
projects,
currentPage,
totalItems,
itemsPerPage,
isLoading,
selectedSort,
onPageChange,
onItemsPerPageChange,
onSortChange,
onStatusChange,
}: Props = $props();
let openRow = $state<number | null>(null);
const statusLabel = (status: string) =>
$t(`domain.project.status.${status.replaceAll(".", "_")}`);
const date = (value: string) => (value ? formatDate(new Date(value), $locale) : "—");
function detailFields(project: ReviewProjectRow): DetailsField[] {
const label = (key: string) => $t(`pages.admin.reviews.projects.table.rows.details.${key}`);
// ponytail: phone, risk, pending messages and advisor have no API yet — placeholders
return [
{ label: label("email"), value: project.email || "—" },
{ label: label("phone"), value: "—" },
{ label: label("dateRelease"), value: date(project.dateRelease) },
{ label: label("dateCreated"), value: date(project.dateCreated) },
{ label: label("lastInteraction"), value: date(project.dateUpdated) },
{ label: label("risk"), value: "—" },
{ label: label("pendingMessages"), value: "—" },
{ label: label("minOptim"), value: project.minOptim },
];
}
const btnLabel = (key: string) =>
$t(`pages.admin.reviews.projects.table.rows.details.btns.${key}`);
</script>
<DataTable
{headers}
rows={projects}
{isLoading}
emptyMessage="pages.admin.projects.table.rows.noData"
{currentPage}
{totalItems}
{itemsPerPage}
itemsPerPageLabel={PROJECT_ITEMS_PER_PAGE_LABEL}
itemsPerPageOptions={[...ADMIN_ITEMS_PER_PAGE_OPTIONS]}
sortOptions={projectSortOptions}
{selectedSort}
sortLabel="pages.admin.projects.filters.order.title"
{onPageChange}
{onItemsPerPageChange}
onSort={(k) => onSortChange?.(k as ProjectSortKey)}
onRowClick={(_row, i) => (openRow = openRow === i ? null : i)}
bind:expandedRowIndex={openRow}
>
{#snippet children(project: ReviewProjectRow, i: number)}
<TableBodyCell class="border-variant1 max-w-60 rounded-l-md border-t border-b border-l p-4">
<p class="text-content truncate">{project.name}</p>
</TableBodyCell>
<TableBodyCell class="border-variant1 text-content border-t border-b p-4">
<span class="block truncate">{project.promoter}</span>
</TableBodyCell>
<TableBodyCell class="border-variant1 text-content border-t border-b p-4">
{date(project.dateUpdated)}
</TableBodyCell>
<TableBodyCell class="border-variant1 border-t border-b p-4">
<div onclick={(e) => e.stopPropagation()} role="presentation">
<select
class="border-secondary text-secondary bg-purple-soft w-full rounded-sm border py-1 text-sm"
value={project.status}
aria-label={$t("pages.admin.reviews.projects.table.headers.status")}
onchange={(e) =>
onStatusChange?.(project.id, e.currentTarget.value as ProjectStatus)}
>
{#each CAMPAIGN_REVIEW_STATUSES as status (status)}
<option value={status}>{statusLabel(status)}</option>
{/each}
</select>
</div>
</TableBodyCell>
<TableBodyCell class="border-variant1 text-content border-t border-b p-4">
{$t("pages.admin.reviews.projects.table.rows.unassigned")}
</TableBodyCell>
<TableBodyCell
class="border-variant1 text-content rounded-r-md border-t border-r border-b p-4"
>
<Chevron
direction={openRow === i ? "up" : "down"}
width="20"
height="20"
class="mx-auto"
/>
</TableBodyCell>
{/snippet}
{#snippet details(project: ReviewProjectRow)}
<DetailsRow fields={detailFields(project)} columns={4}>
{#snippet actions()}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-5">
<Button kind="ghost" size="sm" href={`/${$locale}/project/${project.slug}`}>
{btnLabel("preview")}
</Button>
<Button
kind="ghost"
size="sm"
href={`/${$locale}/user/${project.promoterHandle}`}
>
{btnLabel("promoter")}
</Button>
<Button
kind="ghost"
size="sm"
href={`/${$locale}/project/${project.slug}/edit`}
>
{btnLabel("config")}
</Button>
<!-- ponytail: advisory/advisor endpoints don't exist yet -->
<Button kind="ghost" size="sm" disabled>{btnLabel("advisory")}</Button>
<Button kind="ghost" size="sm" disabled>{btnLabel("assignAdvisor")}</Button>
</div>
{/snippet}
{#snippet footer()}
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<p class="text-content">
<span class="text-sm">
{$t("pages.admin.reviews.projects.table.rows.details.lastModified")}
</span>
<span class="font-bold">{date(project.dateUpdated)}</span>
</p>
<Button kind="secondary" size="sm" disabled>
<Comments size={20} class="shrink-0" />
{btnLabel("annotations")}
</Button>
</div>
{/snippet}
</DetailsRow>
{/snippet}
</DataTable>
-9
View File
@@ -1,9 +0,0 @@
<script lang="ts">
import { clearForUser } from "../../stores/checkoutsStore";
let { userId }: { userId: number } = $props();
$effect(() => {
clearForUser(userId);
});
</script>
@@ -0,0 +1,77 @@
<script lang="ts">
import { get } from "svelte/store";
import { cart, clearForUser } from "../../stores/checkoutsStore";
let {
userId,
checkoutId,
}: {
userId?: number;
checkoutId: string;
} = $props();
/**
* Submits the paid cart items to `/api/checkout/rewardClaims`, where the items are
* matched to the checkout's GatewayCharges and the ProjectRewardClaims are
* created. Running that on the server means a page close mid-request cannot
* leave the claims half-done: once this request is sent, the endpoint
* completes every claim on its own.
*/
async function claimRewards() {
try {
// Guard: a finalised cart must never be re-claimed. The store's
// soft-delete keeps the items around (just flags `finalised:
// true`), so without this there would be nothing stopping the
// $effect below from re-submitting the same claims forever.
const currentCart = get(cart);
if (currentCart.finalised === true) return;
const items = Object.values(currentCart.items);
const hasRewards = items.some(
(item) => item.kind === "reward" && item.reward?.id != null,
);
if (!hasRewards) return;
const response = await fetch("/api/checkout/rewardClaims", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checkoutId, items }),
});
if (!response.ok) {
throw new Error(`Claims endpoint replied ${response.status}`);
}
const { failed, unmatched }: { failed: number; unmatched: number } =
await response.json();
// Only clear the user's cart once every claim succeeded. Unmatched
// items and rejected claims keep the cart intact so the data can be
// inspected and a later run re-tries — claims that already exist are
// idempotent, so re-trials are safe.
if (failed > 0 || unmatched > 0) {
throw new Error(`Claims not created — failed: ${failed}, unmatched: ${unmatched}`);
}
if (userId != null) clearForUser(userId);
} catch (err) {
console.error("[RewardClaims] error:", err);
}
}
// Submit claims when the cart holds rewards. The `clearForUser` above
// finalises the cart, so later effect runs exit early on the `!hasRewards`
// or `finalised` guards.
$effect(() => {
if (
Object.values(get(cart).items).some(
(item) => item.kind === "reward" && item.reward?.id != null,
)
) {
void claimRewards();
}
});
</script>
+1 -1
View File
@@ -15,7 +15,7 @@
<a
{href}
class={twMerge(
"text-variant1 hover:text-purple-soft focus:ring-purple-soft focus:ring-offset-secondary rounded-sm transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:outline-none",
"hover:text-variant3 focus:ring-purple-soft focus:ring-offset-secondary rounded-sm transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:outline-none",
customClass,
)}
>
+3 -4
View File
@@ -13,11 +13,10 @@
</script>
<div class="flex gap-2">
<Button size="sm" kind="secondary" onclick={openSearch}>
<SearchIcon width="16" height="16" />
{$t("common.header.search")}
<Button size="md" kind="ghost" onclick={openSearch} class="inset-0 border-none inset-ring-0">
<SearchIcon width="32" height="32" class="text-content" />
</Button>
<Button size="sm" kind="primary" onclick={goToCreateProject}>
<Button size="md" kind="primary" onclick={goToCreateProject}>
{$t("common.header.createProject")}
</Button>
</div>
@@ -23,8 +23,6 @@
function selectLanguage(code: string) {
open = false;
selected = code;
onSelect?.(code);
}
function toggleDropdown() {
+4 -4
View File
@@ -16,7 +16,7 @@
{#if hero}
<section class="wrapper hero-section flex">
<div class="container mx-auto py-16 lg:py-24">
<div class="container mx-auto py-16 lg:py-12">
<Grid
class={[
"grid-cols-1 items-center gap-12",
@@ -26,7 +26,7 @@
<!-- Hero Content -->
<div class={["space-y-8", !hasMedia && "lg:col-span-2"]}>
<h1
class="text-secondary text-4xl leading-tight font-bold lg:text-5xl xl:text-6xl"
class="text-primary text-4xl leading-tight font-bold lg:text-5xl xl:text-6xl"
>
{hero.title}
</h1>
@@ -39,7 +39,7 @@
{#if hero.primaryCtaText && hero.primaryCtaLink}
<a
href={hero.primaryCtaLink}
class="text-secondary bg-purple-soft inline-block rounded-full border-0 px-8 py-4 text-center font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
class="text-primary bg-purple-soft inline-block rounded-full border-0 px-8 py-4 text-center font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
>
{hero.primaryCtaText}
</a>
@@ -47,7 +47,7 @@
{#if hero.secondaryCtaText && hero.secondaryCtaLink}
<a
href={hero.secondaryCtaLink}
class="bg-primary text-secondary inline-block rounded-full border-0 px-8 py-4 text-center font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
class="text-content inline-block rounded-full border-0 px-8 py-4 text-center font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
>
{hero.secondaryCtaText}
</a>
+1 -13
View File
@@ -27,18 +27,6 @@ const blogPost = {
{blogPost.excerpt}
</p>
</div>
<!-- Button -->
<div class="flex">
<a
href={blogPost.href}
class="group border-secondary hover:bg-secondary flex items-center justify-center gap-2 rounded-3xl border px-6 py-4 transition-all duration-200"
>
<span class="text-secondary text-base font-bold group-hover:text-white">
{blogPost.buttonText}
</span>
</a>
</div>
</div>
<!-- Right Side - Blog Image (background so height follows left content) -->
@@ -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');"
>
</div>
</div>
@@ -0,0 +1,50 @@
<script module>
import { defineMeta } from "@storybook/addon-svelte-csf";
import CampaignCard from "./CampaignCard.svelte";
const eur = (amount) => ({ amount, currency: "EUR" });
const inDays = (days) => new Date(Date.now() + days * 86400000).toISOString();
const campaign = {
id: 1,
slug: "somos-la-resistencia",
title: "Somos la resistencia: hagamos crecer el CE Europa",
image: "/images/project/placeholder-project-update.jpg",
status: "in_campaign",
calendar: { release: inDays(-29), minimum: inDays(11), optimum: inDays(51) },
minimum: eur(152000),
optimum: eur(80052000),
obtained: eur(60000),
};
const { Story } = defineMeta({
component: CampaignCard,
title: "Library/CampaignCard",
tags: ["autodocs"],
args: { size: "small", campaign, class: "max-w-[437px]" },
});
</script>
<Story name="Towards minimum" />
<Story
name="Matchfunding"
args={{ campaign: { ...campaign, matchCallSubmissions: ["/v4/match_call_submissions/1"] } }}
/>
<Story name="Towards optimum" args={{ campaign: { ...campaign, obtained: eur(40000000) } }} />
<Story
name="Finished"
args={{
campaign: {
...campaign,
status: "funding.paid",
obtained: eur(99999900),
matchCallSubmissions: ["/v4/match_call_submissions/1"],
},
}}
/>
<Story name="Large" args={{ size: "large", class: "max-w-[899px]" }} />
+101 -115
View File
@@ -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 },
);
</script>
<div
class={twMerge(
"border-grey grow basis-0 rounded-4xl border bg-white p-6 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)]",
"border-grey bg-purple-soft grow basis-0 rounded-xl border-2 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)]",
sizeClasses,
className,
)}
data-testid="campaign-card"
>
<a href="/project/{campaign.slug}">
<div class="flex flex-col gap-4 md:gap-6">
<!-- Project Image -->
<div
class="relative {imageHeight} w-full rounded-3xl bg-cover bg-center"
style="background-image: url('{campaign.image}')"
>
<!-- Tags Overlay (top-left) -->
<div class="flex flex-col gap-6">
<div class="relative">
<img
src={campaign.image}
alt={campaign.title}
class="h-80 w-full rounded-t-xl object-cover md:h-97.25"
/>
<div class="absolute top-4 left-4 flex flex-wrap gap-2">
<!-- Matchfunding Tag (conditional) -->
{#if campaign.hasMatchfunding}
<Tag>
<Flames />
{#if ownedConfig?.tagLabel}
<Tag variant="bold">{ownedConfig.tagLabel}</Tag>
{/if}
{#if isFinished}
<Tag variant="bold">
<Ok width={16} height={16} />
<span>{$t("pages.home.campaigns.finished")}</span>
</Tag>
{:else if daysRemaining !== undefined}
<Tag variant="bold">
<Clock width={16} height={16} />
<span>
{$t("pages.home.campaigns.daysRemaining", { days: daysRemaining })}
</span>
</Tag>
{/if}
{#if hasMatchfunding}
<Tag variant="bold">
<Flames width={16} height={16} />
<span>{$t("pages.home.campaigns.matchfunding")}</span>
</Tag>
{/if}
<!-- Additional Tags -->
{#if campaign.tags}
{#each campaign.tags as tag}
<Tag>
{tag}
</Tag>
{/each}
{/if}
{#each campaign.tags ?? [] as tag}
<Tag variant="bold">{tag}</Tag>
{/each}
</div>
<!-- Status Badge (top-right) -->
{#if campaign.status === "in_campaign"}
<div class="absolute top-4 right-4">
<CampaignStatusBadge text={statusBadgeText} />
</div>
{#if hasReachedMinimum}
<span
class="border-secondary text-secondary absolute bottom-4 left-4 rounded-lg border bg-white px-2 py-1 text-xs font-medium"
>
{$t("pages.home.campaigns.minimumExceeded")}
</span>
{/if}
</div>
<!-- Project Content -->
<div class="flex flex-col gap-4 md:gap-6">
<!-- Days Remaining & Category -->
<div class="flex items-center gap-2 md:gap-4">
<!-- Status Tag (owned projects section) -->
{#if ownedConfig?.tagLabel}
<Tag>
{ownedConfig.tagLabel}
</Tag>
{/if}
<!-- Days Remaining -->
{#if campaign.daysRemaining !== undefined}
<Tag variant="bold">
<Clock />
<span class="text-sm text-black">
{$t("pages.home.campaigns.daysRemaining", {
days: campaign.daysRemaining,
})}
</span>
</Tag>
{/if}
<!-- Category (display only first) -->
{#if firstCategory()}
<Tag variant="bold">
<Clock />
<span class="text-sm text-black">
{$t(`categories.${firstCategory()}`)}
</span>
</Tag>
{/if}
</div>
<!-- Title -->
<div class="flex flex-col gap-4 p-4">
<Title
level={3}
variant="subsection"
color="secondary"
class="h-16 overflow-hidden leading-8"
class="line-clamp-2 h-16 leading-8"
>
{campaign.title}
</Title>
<!-- Funding Information -->
{#if !ownedConfig || ownedConfig.showMoney !== false}
<div class="flex flex-col gap-2">
<!-- Obtained Amount -->
<div class="flex items-start justify-between">
<div class="flex flex-col gap-1">
<span class="text-base text-black"
>{$t("pages.home.campaigns.obtained")}</span
>
<span class="text-double leading-10 font-bold text-black">
{#if obtained}
{formatCurrency(obtained)}
{:else}
<span class="text-content text-sm"
>{$t("system.loading")}</span
>
{/if}
</span>
{#if obtained}
<div class="flex h-4 gap-1">
{#if hasReachedMinimum}
<div class="bg-primary h-full w-1/3 rounded-2xl"></div>
{/if}
<div
class="border-variant1 h-full flex-1 overflow-hidden rounded-2xl border bg-white"
>
<div
class="h-full rounded-2xl {hasReachedMinimum
? 'bg-primary'
: 'bg-tertiary'}"
style="width: {barFill * 100}%"
></div>
</div>
<!-- Remaining to Goal -->
<div class="flex flex-col gap-2 text-right">
{#if campaign.optimum && hasReachedMinimum}
<span class="text-base text-black">
{$t("pages.home.campaigns.optimum")}
</span>
<span class="text-2xl font-bold text-black">
{formatCurrency(campaign.optimum)}
</span>
</div>
{/if}
<div class="flex items-start justify-between text-black">
<div class="flex flex-col gap-1">
<span class="text-base">{$t("pages.home.campaigns.obtained")}</span>
<span class="text-2xl leading-8 font-bold">
{#if obtained}
{formatCurrency(obtained)}
{:else}
<span class="text-base text-black">
{$t("pages.home.campaigns.minimum")}
</span>
<span class="text-2xl font-bold text-black">
{formatCurrency(campaign.minimum)}
</span>
<span class="text-content text-sm">{$t("system.loading")}</span>
{/if}
</div>
</span>
</div>
<div class="flex flex-col gap-1 text-right">
<span class="text-base">{$t(`pages.home.campaigns.${goal.key}`)}</span>
<span class="text-2xl leading-8 font-bold">
{formatCurrency(goal.money)}
</span>
</div>
</div>
{/if}
@@ -1,16 +0,0 @@
<script module>
import { defineMeta } from "@storybook/addon-svelte-csf";
import CampaignStatusBadge from "./CampaignStatusBadge.svelte";
const { Story } = defineMeta({
component: CampaignStatusBadge,
title: "Library/CampaignStatusBadge",
tags: ["autodocs"],
args: {
text: "Active",
},
});
</script>
<Story name="Active" args={{ text: "Active" }} />
@@ -1,19 +0,0 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
text: string;
class?: ClassNameValue;
}
let { text, class: classes = "" }: Props = $props();
</script>
<div
class={twMerge(
"rounded-full bg-white px-4 py-2 text-sm font-bold whitespace-nowrap text-black shadow-md",
classes,
)}
>
{text}
</div>
+2 -2
View File
@@ -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(
<!-- "View All" Button -->
<a href="/search" aria-label={t("pages.home.campaigns.viewAll")}>
<Button kind="secondary" size="md">
<Button kind="ghost" size="md">
<Arrow />
<span class="text-secondary text-base font-bold">
{t("pages.home.campaigns.viewAll")}
+6 -9
View File
@@ -9,10 +9,9 @@ import { extractId } from "../../utils/extractId";
const { t } = Astro.locals;
const chosenProjects = {
"impulsa-la-cooperativa-de-informacion-climatica": "",
"fons-cooperatiu-front-l-emergencia-social-i-sanita": "",
"la-benefica":
"https://i.vimeocdn.com/video/1286954665-1fc1ccfa1b381946d23072ca518e4f0094c566416243095e6_900x500?region=us",
"salvem-el-delta-del-llobregat": "",
"la-nuestra": "",
"portal-de-ressenyes-sobre-pisos": "",
};
// Mock campaign data - using same campaigns as the main Campaigns component
@@ -28,7 +27,7 @@ const { data: projects } = await apiProjectsGetCollection({
const campaigns: Campaign[] = await Promise.all(
projects!.map(async (project) => {
const { data: accounting } = await apiAccountingsIdGet({
path: { id: extractId(project.accounting) },
path: { id: extractId(project.accounting)! },
});
return {
@@ -36,9 +35,7 @@ const campaigns: Campaign[] = await Promise.all(
size: "small",
slug: project.slug!,
title: project.title!,
image:
chosenProjects[project.slug! as keyof typeof chosenProjects] ||
project.video?.thumbnail!,
image: project.cover || project.video?.thumbnail!,
minimum: project.budget?.minimum?.money!,
optimum: project.budget?.optimum?.money,
obtained: accounting!.balance as Money,
@@ -47,7 +44,7 @@ const campaigns: Campaign[] = await Promise.all(
);
// Get campaigns for the currently active tab
const displayCampaigns = campaigns.slice(0, 4); // Show first 4 campaigns
const displayCampaigns = campaigns.slice(0, 3);
---
<section class="wrapper flex flex-col gap-6">
+26 -63
View File
@@ -2,8 +2,6 @@
// Matchfunding section derived from Figma design
// Uses design tokens and Tailwind utility classes.
// Later this could be powered by real API data.
import hacenderaImage from "./hacendera-de-proyectos.png";
import reeiximImage from "./reeixim-loesst.png";
const { t } = Astro.locals;
@@ -22,6 +20,7 @@ interface CallCardStat {
interface CallCard {
title: string;
description: string;
stats: CallCardStat[]; // supports 2 or more stats depending on the card
cta: string;
tag?: string;
@@ -31,27 +30,23 @@ interface CallCard {
const calls: CallCard[] = [
{
title: "Hacendera de proyectos",
stats: [
{ label: t("pages.home.matchfunding.cards.grant"), value: "200.000€" },
{ label: t("pages.home.matchfunding.cards.inscription"), value: "15/08/2025" },
],
title: "Archicare.\nKeep democratic memory alive. Put it to work.",
description:
"Archicare supports feminist and community-led projects that preserve democratic memory and turn archives and lived experience into infrastructures for care, resistance and collective action.",
stats: [],
// Figma CTA copy
cta: "Inscríbete en la convocatoria",
background: hacenderaImage.src,
background: "/images/home/archicare-purple.png",
tag: "/images/project/09_shelf_ARCHICARE.svg",
},
{
title: "Reeixim L`oesst",
stats: [
{ label: t("pages.home.matchfunding.cards.grant"), value: "200.000€" },
{ label: t("pages.home.matchfunding.cards.participants"), value: "15 proyectos" },
// TODO i18n: add proper key for "Proyectos acabados con éxito"
{ label: "Proyectos acabados con éxito", value: "4 proyectos" },
],
title: "Distributed infrastructures.\nOwn the infrastructure. Share the power.",
description:
"Building community-owned, cooperative and federated alternatives to platform dependency",
stats: [],
cta: "Ver las campañas de la convocatoria",
background: reeiximImage.src,
tag: `Finalizada 24/09/25`,
finished: true,
background: "/images/home/distrib-transparent.png",
tag: "/images/project/10_shelf_DisInfra.svg",
},
];
---
@@ -63,55 +58,30 @@ const calls: CallCard[] = [
<div class="flex flex-col gap-6 lg:flex-row lg:justify-between">
<!-- What is Matchfunding -->
<div
class="backdrop-blur-md/0 flex flex-col gap-10 rounded-2xl bg-[rgba(143,124,146,0.4)] p-10 lg:w-1/2"
class="backdrop-blur-md/0 flex flex-col gap-10 rounded-2xl bg-[rgba(143,124,146,0.4)] p-10"
>
<div class="text-purple-soft flex flex-col gap-4 text-balance">
<h2 class="text-purple-soft text-4xl font-bold">
<div class="text-purple flex flex-col gap-4 text-balance">
<h2 class="text-purple text-4xl font-bold">
{t("pages.home.matchfunding.title")}
</h2>
<p class="text-base leading-6 text-white">
{t("pages.home.matchfunding.description")}
</p>
</div>
<div class="flex flex-wrap gap-4">
<!-- <div class="flex flex-wrap gap-4">
<a
href="/matchfunding"
class="group relative flex items-center gap-2 rounded-3xl border border-white/40 bg-white/20 px-6 py-4 text-sm font-bold text-white backdrop-blur-md transition hover:bg-white/30"
class="group relative flex items-center gap-2 rounded-xl border border-white/40 bg-white/20 px-6 py-4 text-sm font-bold text-white backdrop-blur-md transition hover:bg-white/30"
>
{t("pages.home.matchfunding.actions.learnMore")}
</a>
<a
href="/convocatorias"
class="bg-purple-soft text-secondary rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
class="bg-purple-soft text-content rounded-xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
>
{t("pages.home.matchfunding.actions.viewCalls")}
</a>
</div>
</div>
<!-- Impact data -->
<div
class="backdrop-blur-md/0 flex flex-col gap-10 rounded-2xl bg-[rgba(143,124,146,0.4)] p-10 lg:w-1/2"
>
<div class="text-purple-soft flex flex-col gap-4">
<h3 class="text-purple-soft text-4xl font-bold">
{t("pages.home.matchfunding.impact.title")}
</h3>
<p class="text-base leading-6 text-white">
{t("pages.home.matchfunding.impact.description")}
</p>
</div>
<div class="flex flex-wrap items-start justify-between gap-8">
{
impact.map((item) => (
<div class="flex flex-col gap-2" data-label={item.label}>
<span class="text-sm text-white">{item.label}</span>
<span class="text-4xl font-bold tracking-tight">
{item.value}
</span>
</div>
))
}
</div>
</div> -->
</div>
</div>
@@ -120,12 +90,13 @@ const calls: CallCard[] = [
{
calls.map((card) => (
<div
class="relative flex w-full flex-col justify-between overflow-hidden rounded-3xl bg-cover bg-center text-white lg:w-1/2"
class="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-cover bg-center text-shadow-2xs lg:w-1/2"
style={`background-image:url(${card.background});`}
>
<div class="relative z-10 flex flex-col gap-10 p-10">
<div class="flex flex-col gap-4">
<div class="flex w-1/2 flex-col gap-4">
<h4 class="text-3xl font-bold">{card.title}</h4>
<p>{card.description}</p>
<div class="flex gap-6">
{card.stats.map((s) => (
<div class="flex flex-col">
@@ -135,18 +106,10 @@ const calls: CallCard[] = [
))}
</div>
</div>
<a
href="/convocatorias"
class="bg-purple-soft text-secondary w-fit rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
>
{card.cta}
</a>
</div>
{card.tag && (
<div class="bg-tertiary absolute top-6 right-6 z-20 rounded px-2 py-1 text-xs font-medium text-white">
{card.tag}
</div>
)}
<div class="border-xl absolute top-6 right-6 z-20 rounded bg-white px-2 py-1 text-xs font-medium">
<img class="h-12" src={card.tag} />
</div>
<div class="pointer-events-none absolute inset-0 z-0 bg-gradient-to-r from-black/70 to-black/30" />
</div>
))
+18 -18
View File
@@ -15,11 +15,11 @@ const { t } = Astro.locals;
<!-- Header -->
<div class="flex flex-col gap-2">
<h2
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.theoryToAction.title")}
</h2>
<p class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<p class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.theoryToAction.subtitle")}
</p>
</div>
@@ -28,31 +28,31 @@ const { t } = Astro.locals;
<div class="flex flex-col items-center justify-between gap-4 md:flex-row md:gap-0">
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.theoryToAction.projectsReceived.value")}
</div>
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<div class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.theoryToAction.projectsReceived.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.theoryToAction.projectsPublished.value")}
</div>
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<div class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.theoryToAction.projectsPublished.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.theoryToAction.successRate.value")}
</div>
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<div class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.theoryToAction.successRate.label")}
</div>
</div>
@@ -62,7 +62,7 @@ const { t } = Astro.locals;
<!-- Button -->
<a
href="https://stats.goteo.org"
class="border-secondary text-secondary hover:bg-secondary flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
class="border-content text-content hover:bg-content flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
>
{t("pages.home.stats.theoryToAction.button")}
</a>
@@ -74,11 +74,11 @@ const { t } = Astro.locals;
<!-- Header -->
<div class="flex flex-col gap-2">
<h2
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.whyGoteo.title")}
</h2>
<p class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<p class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.whyGoteo.subtitle")}
</p>
</div>
@@ -87,31 +87,31 @@ const { t } = Astro.locals;
<div class="flex flex-col items-center justify-between gap-4 md:flex-row md:gap-0">
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.whyGoteo.community.value")}
</div>
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<div class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.whyGoteo.community.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.whyGoteo.averageDonors.value")}
</div>
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<div class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.whyGoteo.averageDonors.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-12"
class="text-content text-2xl leading-tight font-bold md:text-4xl md:leading-12"
>
{t("pages.home.stats.whyGoteo.totalRaised.value")}
</div>
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
<div class="text-content text-sm leading-5 md:text-base md:leading-6">
{t("pages.home.stats.whyGoteo.totalRaised.label")}
</div>
</div>
@@ -121,7 +121,7 @@ const { t } = Astro.locals;
<!-- Button -->
<a
href="/about"
class="border-secondary text-secondary hover:bg-secondary flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
class="border-content text-content hover:bg-content flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
>
{t("pages.home.stats.whyGoteo.button")}
</a>
+29 -23
View File
@@ -1,24 +1,30 @@
<svg
width="97"
height="24"
class="fill-black"
viewBox="0 0 97 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.8701 7.67198C18.4595 7.81705 16.9816 7.8935 15.5572 8.02467C15.2749 7.55819 14.9676 7.16902 14.7414 6.85108C14.2951 6.22563 13.4008 5.40125 12.5271 5.092C8.78915 3.77074 4.94931 6.30468 4.48573 10.4865C4.06359 14.2835 5.60971 18.2143 8.79778 19.0474C12.5617 20.0325 15.1817 17.6193 15.8015 14.7239C14.3961 14.7239 12.4926 14.7821 10.8955 14.7066C10.8955 13.4948 10.8955 12.2847 10.8955 11.0703C14.0422 11.0703 17.1897 11.0703 20.3354 11.0703C20.3225 15.7464 19.3927 18.5922 17.1888 20.6874C13.4327 24.2595 6.56622 23.8659 3.08636 20.452C0.864293 18.2716 -0.271774 14.8291 0.0554068 10.4813C0.435247 5.45164 3.72777 1.36449 9.37963 0.864131C15.2154 0.351608 18.6486 3.23302 19.8701 7.67198Z"
></path>
<path
d="M31.7556 0.987442C38.1507 0.661686 42.7028 5.38297 42.7131 11.8946C42.7269 18.9031 37.7795 23.1527 32.1061 23.1527C26.4801 23.1536 22.484 19.096 21.9661 13.771C21.2591 6.45492 25.2232 1.31841 31.7556 0.987442ZM26.8626 15.4119C27.6542 17.4933 29.6052 19.122 32.1069 19.1655C40.3823 19.3123 40.2744 3.84888 31.5242 4.97469C27.4608 5.49677 25.0134 10.5577 26.8626 15.4119Z"
></path>
<path
d="M85.4884 0.98751C91.4234 0.661755 95.8977 4.71502 96.4433 10.3701C97.1287 17.4464 92.7735 23.1528 85.9554 23.1528C79.6388 23.1537 75.7127 18.3099 75.6988 12.2456C75.685 6.0137 79.3047 1.32456 85.4884 0.98751ZM83.0418 5.67839C78.7376 8.41995 79.0224 19.2107 86.0719 19.1655C91.3413 19.1308 93.7637 11.9068 90.9676 7.55474C89.9161 5.92075 87.8537 4.59341 85.2561 4.97476C84.0847 5.14589 83.6686 5.27706 83.0418 5.67839Z"
></path>
<path
d="M58.2159 1.22114C58.2159 2.55109 58.2159 3.88018 58.2159 5.20926C56.39 5.20926 54.5642 5.20926 52.7375 5.20926C52.7375 11.0346 52.7375 16.8591 52.7375 22.6845C51.2985 22.6845 49.8628 22.6845 48.4246 22.6845C48.4246 16.8974 48.4246 11.0216 48.4246 5.23619C46.6799 5.23619 44.8576 5.20926 43.0637 5.20926C43.0637 3.77072 43.0378 2.58932 43.0378 1.22114C48.0482 1.22114 53.2028 1.22114 58.2159 1.22114Z"
></path>
<path
d="M74.0658 1.22117C74.0658 2.51204 74.1193 3.9054 74.1193 5.19539C71.0944 5.19539 68.2094 5.20929 65.0921 5.20929C65.0921 6.73383 65.0921 8.39475 65.0921 9.91755C67.7648 9.91755 70.5152 9.90104 73.484 9.90104C73.484 11.1893 73.484 12.4802 73.484 13.7701C70.6878 13.7701 67.8908 13.7701 65.0921 13.7701C65.0921 15.5787 65.0532 17.3891 65.0532 18.8372C67.9633 18.8372 70.314 18.8128 73.9493 18.8128C73.9493 20.1028 73.9493 21.3937 73.9493 22.6837C69.5215 22.6837 65.0921 22.6837 60.6644 22.6837C60.7403 15.5692 60.5072 8.14109 60.7792 1.22031C65.2078 1.22117 69.6355 1.22117 74.0658 1.22117Z"
></path>
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
let { class: classes }: { class?: ClassNameValue } = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" class={twMerge("h-30 w-60", classes)} viewBox="0 0 260 110">
<g class="fill-content">
<g stroke="#FF4D9A" stroke-width="12" stroke-linecap="round">
<line x1="34" y1="20" x2="34" y2="76" />
<line x1="7" y1="48" x2="61" y2="48" />
<line x1="14" y1="28" x2="54" y2="68" />
<line x1="54" y1="28" x2="14" y2="68" />
</g>
<text
x="82"
y="61"
font-family="Arial, Helvetica, sans-serif"
font-size="54"
font-weight="800">DTF</text
>
<text
x="84"
y="91"
font-family="Arial, Helvetica, sans-serif"
font-size="25"
font-weight="700">by Goteo</text
>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 978 B

@@ -0,0 +1,30 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
class?: ClassNameValue;
width?: string | number;
height?: string | number;
}
let { class: classes = "", width = "32", height = "32" }: Props = $props();
</script>
<svg
{width}
{height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={twMerge("text-secondary", classes)}
>
<circle cx="8" cy="10" r="1.5" fill="currentColor" />
<circle cx="8" cy="16" r="1.5" fill="currentColor" />
<circle cx="8" cy="22" r="1.5" fill="currentColor" />
<path
d="M13 10H25M13 16H25M13 22H25"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
@@ -0,0 +1,34 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
class?: ClassNameValue;
width?: string | number;
height?: string | number;
}
let { class: classes = "", width = "32", height = "32" }: Props = $props();
</script>
<svg
{width}
{height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={twMerge("text-secondary", classes)}
>
<path
d="M7 8H8.5V13M7 13H10M7 18.5C7 17.7 7.7 17 8.5 17C9.3 17 10 17.7 10 18.4C10 19.5 7 20.5 7 23H10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 10H25M14 16H25M14 22H25"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
class?: ClassNameValue;
width?: string | number;
height?: string | number;
}
let { class: classes = "", width = "32", height = "32" }: Props = $props();
</script>
<svg
{width}
{height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={twMerge("text-secondary", classes)}
>
<path d="M7 8V24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
<path
d="M13 11H25M13 16H25M13 21H21"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
@@ -0,0 +1,24 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
class?: ClassNameValue;
width?: string | number;
height?: string | number;
}
let { class: classes = "", width = "56", height = "56" }: Props = $props();
</script>
<svg
{width}
{height}
viewBox="0 0 74 79"
class={twMerge("fill-current", classes)}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M73.7014 17.4323C72.5616 9.05152 65.1774 2.4469 56.424 1.1671C54.9472 0.950843 49.3518 0.163818 36.3901 0.163818H36.2933C23.3281 0.163818 20.5465 0.950843 19.0697 1.1671C10.56 2.41145 2.78877 8.34604 0.903306 16.826C-0.00357854 21.0022 -0.100361 25.6322 0.068112 29.8793C0.308275 35.9699 0.354874 42.0498 0.91406 48.1156C1.30064 52.1448 1.97502 56.1419 2.93215 60.0769C4.72441 67.3445 11.9795 73.3925 19.0876 75.86C26.6979 78.4332 34.8821 78.8603 42.724 77.0937C43.5866 76.8952 44.4398 76.6647 45.2833 76.4024C47.1867 75.8033 49.4199 75.1332 51.0616 73.9562C51.0841 73.9397 51.1026 73.9184 51.1156 73.8938C51.1286 73.8693 51.1359 73.8421 51.1368 73.8144V67.9366C51.1364 67.9107 51.1302 67.8852 51.1186 67.862C51.1069 67.8388 51.0902 67.8184 51.0695 67.8025C51.0489 67.7865 51.0249 67.7753 50.9994 67.7696C50.9738 67.764 50.9473 67.7641 50.9218 67.7699C45.8976 68.9569 40.7491 69.5519 35.5836 69.5425C26.694 69.5425 24.3031 65.3699 23.6184 63.6327C23.0681 62.1314 22.7186 60.5654 22.5789 58.9744C22.5775 58.9477 22.5825 58.921 22.5934 58.8965C22.6043 58.8721 22.621 58.8505 22.6419 58.8336C22.6629 58.8167 22.6876 58.8049 22.714 58.7992C22.7404 58.7934 22.7678 58.794 22.794 58.8007C27.7345 59.9796 32.799 60.5746 37.8813 60.5733C39.1036 60.5733 40.3223 60.5733 41.5447 60.5414C46.6562 60.3996 52.0437 60.1408 57.0728 59.1694C57.1983 59.1446 57.3237 59.1233 57.4313 59.0914C65.3638 57.5847 72.9128 52.8555 73.6799 40.8799C73.7086 40.4084 73.7803 35.9415 73.7803 35.4523C73.7839 33.7896 74.3216 23.6576 73.7014 17.4323ZM61.4925 47.3144H53.1514V27.107C53.1514 22.8528 51.3591 20.6832 47.7136 20.6832C43.7061 20.6832 41.6988 23.2499 41.6988 28.3194V39.3803H33.4078V28.3194C33.4078 23.2499 31.3969 20.6832 27.3894 20.6832C23.7654 20.6832 21.9552 22.8528 21.9516 27.107V47.3144H13.6176V26.4937C13.6176 22.2395 14.7157 18.8598 16.9118 16.3545C19.1772 13.8552 22.1488 12.5719 25.8373 12.5719C30.1064 12.5719 33.3325 14.1955 35.4832 17.4394L37.5587 20.8853L39.6377 17.4394C41.7884 14.1955 45.0145 12.5719 49.2765 12.5719C52.9614 12.5719 55.9329 13.8552 58.2055 16.3545C60.4017 18.8574 61.4997 22.2371 61.4997 26.4937L61.4925 47.3144Z"
fill="inherit"
/>
</svg>
@@ -1,10 +1,24 @@
<script>
<script lang="ts">
import { twMerge } from "tailwind-merge";
import { t } from "../../../i18n/store";
import type { Snippet } from "svelte";
import type { ClassNameValue } from "tailwind-merge";
let {
href,
children,
class: classes = "",
}: { href?: string; children?: Snippet; class?: ClassNameValue } = $props();
</script>
<button
onclick={() => history.back()}
class="group text-content flex w-fit cursor-pointer items-center gap-2 transition-colors duration-200 hover:text-black"
onclick={() => (href ? (window.location.href = href) : history.back())}
class={twMerge(
"group text-content flex w-fit cursor-pointer items-center gap-2 transition-colors duration-200 hover:text-black",
classes,
)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -14,5 +28,11 @@
fill="#462949"
></path>
</svg>
<span class="font-medium">{$t("common.back")}</span>
<span class="font-medium">
{#if children}
{@render children()}
{:else}
{$t("common.back")}
{/if}
</span>
</button>
+3 -3
View File
@@ -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",
@@ -1,5 +1,7 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import { twJoin, twMerge, type ClassNameValue } from "tailwind-merge";
import Check from "../../icons/actions/Check.svelte";
import type { DropdownOption, DropdownVariant } from "./dropdown.types";
@@ -39,10 +41,13 @@
</label>
{:else if variant === "basic"}
<button
class="w-full cursor-pointer p-4 text-base text-black"
class={twMerge("block w-full cursor-pointer p-4 text-base text-black", classes)}
onclick={() => handleChange(option)}
>
{@html option.label}
<span>{@html option.label}</span>
{#if option.selected}
<Check width="20" height="20" class="text-primary shrink-0" />
{/if}
</button>
{/if}
</div>
@@ -35,6 +35,11 @@
chips?: boolean;
/** Renders the body of one pill; defaults to the option label. */
chip?: Snippet<[DropdownOption]>;
/**
* Extra classes for every list item, e.g. to change the text alignment.
* Applied to the <button>/<label> element that holds the option label.
*/
itemClass?: ClassNameValue;
}
let {
@@ -57,6 +62,7 @@
isOpen = $bindable(false),
chips = false,
chip = undefined,
itemClass = undefined,
}: Props = $props();
const listId = $props.id();
@@ -101,6 +107,7 @@
function handleItemChange(option: DropdownOption) {
if (singleSelect) {
selected = [{ ...option, selected: true }];
if (hasSearch && !chips) searchValue = option.label;
isOpen = false;
} else if (option.selected && !isSelected(option)) {
selected = [...selected, option];
@@ -205,15 +212,10 @@
</button>
{/if}
{#if isOpen && renderedItems.length > 0}
<div
class="absolute top-full left-0 z-100 mt-2 w-full flex-col overflow-hidden rounded-lg bg-white shadow-2xl"
>
{#if isOpen}
{#if renderedItems.length}
<div
id={listId}
role="listbox"
aria-multiselectable={!singleSelect}
class="flex max-h-72 w-full flex-col overflow-y-auto"
class="absolute top-full left-0 z-100 mt-2 w-full flex-col overflow-hidden rounded-lg bg-white shadow-2xl"
>
{#each renderedItems as item}
<DropdownItem
@@ -223,10 +225,17 @@
class={twJoin(
item.position === "start" && "rounded-t-lg",
item.position === "end" && "rounded-b-lg",
itemClass,
)}
/>
{/each}
</div>
</div>
{:else if !hasSearch || searchValue.trim()}
<p
class="text-content absolute top-full left-0 z-100 mt-2 w-full rounded-lg bg-white p-4 text-base shadow-2xl"
>
{$t("common.noOptions")}
</p>
{/if}
{/if}
</div>
@@ -32,3 +32,7 @@
>
<Story name="Warning" args={{ variant: "warning" }}>Your session will expire in 5 minutes.</Story>
<Story name="Floating" args={{ variant: "success", floating: true }}
>Floating toast anchored to the bottom-right of the viewport.</Story
>
@@ -14,6 +14,7 @@
button,
link,
class: classes = "",
floating,
showToast = $bindable(false),
children,
}: {
@@ -21,6 +22,7 @@
button?: Snippet;
link?: Snippet;
class?: ClassNameValue;
floating?: boolean;
showToast: boolean;
children: Snippet;
} = $props();
@@ -35,8 +37,11 @@
{#if showToast}
<div
role={variant === "error" ? "alert" : "status"}
class={twMerge(
"flex max-w-360 min-w-67.5 items-start gap-4 self-center rounded-lg border p-6 text-wrap sm:items-center sm:justify-between",
floating &&
"fixed right-4 bottom-4 left-4 z-999 shadow-lg sm:right-6 sm:bottom-6 sm:left-auto sm:max-w-120",
variantStyles[variant],
classes,
)}
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from "../../../i18n/store";
import { DEFAULT_CURRENCY } from "../../../utils/currencies";
import { debounce } from "../../../utils/debounce";
import Close from "../../icons/navigation/Close.svelte";
import DropdownMenu from "../dropdown/DropdownMenu.svelte";
import CurrencyInput from "../inputs/CurrencyInput.svelte";
@@ -57,24 +58,23 @@
suggestOptions = [];
});
let searchTimer: ReturnType<typeof setTimeout> | null = null;
const runSuggest = debounce(async (query: string) => {
const results = (await currentSubject?.suggest?.(query)) ?? [];
suggestOptions = results.map((result) => ({
id: result.value,
label: result.label,
selected: false,
}));
});
function handleSuggest(query: string) {
if (searchTimer) clearTimeout(searchTimer);
if (!query) {
runSuggest.cancel();
suggestOptions = [...dropdownSelected];
return;
}
searchTimer = setTimeout(async () => {
const results = (await currentSubject?.suggest?.(query)) ?? [];
suggestOptions = results.map((result) => ({
id: result.value,
label: result.label,
selected: false,
}));
}, 300);
runSuggest(query);
}
function syncReferent() {
@@ -31,7 +31,23 @@
id: "rte-multiparagraph",
format: "markdown",
placeholder: "Escribe algo...",
value: "Primer párrafo de ejemplo.\n\nSegundo párrafo separado con el mismo espaciado que en la página de campaña.\n\nTercer párrafo con **negrita** y *cursiva* para visualizar el estilo.",
value: "Primer párrafo de ejemplo.\n\nSegundo párrafo separado con el mismo espaciado que en la página de campaña.\n\nTercer párrafo con **negrita**, *cursiva* y un [enlace](https://goteo.org).\n\n- Primer punto\n- Segundo punto\n\n1. Paso uno\n2. Paso dos\n\n> Una cita destacada.",
onChange: () => {},
}}
/>
<Story
name="MinimalToolbar"
args={{
id: "rte-minimal",
format: "markdown",
placeholder: "Escribe algo...",
value: "",
showTextStyle: false,
showAlignment: false,
showImage: false,
showLink: false,
showQuote: false,
onChange: () => {},
}}
/>
@@ -1,5 +1,7 @@
<!--
Tiptap rich text editor with a bold / italic / image / alignment / font-size toolbar.
Tiptap rich text editor with a text style (paragraph / headings 1-4) / bold / italic / image /
link / list / quote / alignment toolbar. Every tool is on by default and can be hidden with its
`show*` prop; hiding a tool only removes its button, the document schema stays the same.
`format` picks the shape of `value` and of what `onChange` reports: Tiptap JSON (the default
and the canonical one), HTML, Markdown or plain text. The editor always works in JSON
@@ -11,24 +13,36 @@
<script lang="ts" generics="F extends RichTextFormat = 'json'">
import { Editor } from "@tiptap/core";
import { Placeholder } from "@tiptap/extensions";
import { untrack } from "svelte";
import { tick, untrack, type Component } from "svelte";
import { twJoin, twMerge, type ClassNameValue } from "tailwind-merge";
import ImageUploadModal from "./ImageUploadModal.svelte";
import TextInput from "./TextInput.svelte";
import { t } from "../../../i18n/store";
import {
ALIGNMENTS as ALIGNMENT_VALUES,
HEADING_LEVELS,
loadMarkdown,
parseRichText,
richTextExtensions,
serializeRichText,
} from "../../../utils/richText";
import Align from "../../icons/Align.svelte";
import ListBullet from "../../icons/editor/ListBullet.svelte";
import ListOrdered from "../../icons/editor/ListOrdered.svelte";
import Quote from "../../icons/editor/Quote.svelte";
import Image from "../../icons/media/Image.svelte";
import Link from "../../icons/media/Link.svelte";
import Chevron from "../../icons/navigation/Chevron.svelte";
import Button from "../buttons/Button.svelte";
import type { UploadedObject } from "../../../utils/media/objectStorage.types";
import type { Alignment, RichTextFormat, RichTextValue } from "../../../utils/richText";
import type {
Alignment,
HeadingLevel,
RichTextFormat,
RichTextValue,
} from "../../../utils/richText";
interface RichTextEditorProps {
id: string;
@@ -42,11 +56,19 @@
class?: ClassNameValue;
minLength?: number;
maxLength?: number;
showFontSize?: boolean;
showTextStyle?: boolean;
showAlignment?: boolean;
showBold?: boolean;
showItalic?: boolean;
showImage?: boolean;
showLink?: boolean;
showBulletList?: boolean;
showOrderedList?: boolean;
showQuote?: boolean;
}
type ToolbarIcon = Component<{ width?: string; height?: string; class?: ClassNameValue }>;
interface ToolbarButton {
id: string;
labelKey: string;
@@ -54,7 +76,7 @@
run: () => void;
align?: Alignment;
glyph?: { text: string; class: string };
image?: boolean;
icon?: ToolbarIcon;
}
let {
@@ -69,9 +91,15 @@
class: className = "",
minLength,
maxLength,
showFontSize = true,
showTextStyle = true,
showAlignment = true,
showBold = true,
showItalic = true,
showImage = true,
showLink = true,
showBulletList = true,
showOrderedList = true,
showQuote = true,
}: RichTextEditorProps = $props();
const ALIGNMENT_LABEL_KEYS: Record<Alignment, string> = {
@@ -80,12 +108,23 @@
right: "domain.richTextEditor.alignRight",
};
const FONT_SIZES = ["12px", "14px", "16px", "18px", "20px", "24px"];
const DEFAULT_FONT_SIZE = "16px";
type TextStyle = "paragraph" | `${HeadingLevel}`;
const TEXT_STYLES: { value: TextStyle; labelKey: string; level?: HeadingLevel }[] = [
{ value: "paragraph", labelKey: "domain.richTextEditor.paragraph" },
...HEADING_LEVELS.map((level) => ({
value: `${level}` as const,
labelKey: "domain.richTextEditor.heading",
level,
})),
];
let editorElement = $state<HTMLDivElement>();
let editor = $state<Editor | null>(null);
let showImageModal = $state(false);
let showLinkPopover = $state(false);
let linkUrl = $state("");
let linkPopoverElement = $state<HTMLDivElement>();
// The markdown converters are loaded on demand, so the editor waits for them before mounting.
let markdownReady = $state(false);
@@ -94,38 +133,77 @@
let toolbar = $state({
bold: false,
italic: false,
link: false,
bulletList: false,
orderedList: false,
blockquote: false,
alignment: "left" as Alignment,
fontSize: DEFAULT_FONT_SIZE,
textStyle: "paragraph" as TextStyle,
characters: 0,
});
const markButtons: ToolbarButton[] = $derived([
{
id: "bold",
labelKey: "domain.richTextEditor.bold",
active: toolbar.bold,
run: () => editor?.chain().focus().toggleBold().run(),
glyph: { text: "B", class: "font-bold" },
},
{
id: "italic",
labelKey: "domain.richTextEditor.italic",
active: toolbar.italic,
run: () => editor?.chain().focus().toggleItalic().run(),
glyph: { text: "I", class: "font-serif italic" },
},
...(showImage
? [
{
id: "image",
labelKey: "domain.richTextEditor.image",
active: false,
run: () => (showImageModal = true),
image: true,
},
]
: []),
]);
const markButtons: ToolbarButton[] = $derived(
[
{
visible: showBold,
id: "bold",
labelKey: "domain.richTextEditor.bold",
active: toolbar.bold,
run: () => editor?.chain().focus().toggleBold().run(),
glyph: { text: "B", class: "font-bold" },
},
{
visible: showItalic,
id: "italic",
labelKey: "domain.richTextEditor.italic",
active: toolbar.italic,
run: () => editor?.chain().focus().toggleItalic().run(),
glyph: { text: "I", class: "font-serif italic" },
},
{
visible: showImage,
id: "image",
labelKey: "domain.richTextEditor.image",
active: false,
run: () => (showImageModal = true),
icon: Image,
},
{
visible: showLink,
id: "link",
labelKey: "domain.richTextEditor.link",
active: toolbar.link || showLinkPopover,
run: toggleLinkPopover,
icon: Link,
},
{
visible: showBulletList,
id: "bulletList",
labelKey: "domain.richTextEditor.bulletList",
active: toolbar.bulletList,
run: () => editor?.chain().focus().toggleBulletList().run(),
icon: ListBullet,
},
{
visible: showOrderedList,
id: "orderedList",
labelKey: "domain.richTextEditor.orderedList",
active: toolbar.orderedList,
run: () => editor?.chain().focus().toggleOrderedList().run(),
icon: ListOrdered,
},
{
visible: showQuote,
id: "quote",
labelKey: "domain.richTextEditor.quote",
active: toolbar.blockquote,
run: () => editor?.chain().focus().toggleBlockquote().run(),
icon: Quote,
},
]
.filter(({ visible }) => visible)
.map(({ visible: _visible, ...button }) => button),
);
const alignButtons: ToolbarButton[] = $derived(
ALIGNMENT_VALUES.map((alignment) => ({
@@ -158,13 +236,68 @@
chain?.run();
}
function toggleLinkPopover() {
if (showLinkPopover) {
closeLinkPopover();
return;
}
linkUrl = editor?.getAttributes("link").href ?? "";
showLinkPopover = true;
tick().then(() => linkPopoverElement?.querySelector("input")?.focus());
}
function setTextStyle(textStyle: TextStyle) {
const chain = editor?.chain().focus();
if (textStyle === "paragraph") chain?.setParagraph().run();
else chain?.setHeading({ level: Number(textStyle) as HeadingLevel }).run();
}
function closeLinkPopover() {
showLinkPopover = false;
editor?.commands.focus();
}
function applyLink() {
const href = linkUrl.trim();
const chain = editor?.chain().focus().extendMarkRange("link");
if (href) chain?.setLink({ href }).run();
else chain?.unsetLink().run();
showLinkPopover = false;
}
function removeLink() {
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
showLinkPopover = false;
}
// The editor usually sits inside a page <form>, so the popover cannot be a nested form:
// Enter and Escape are handled here instead of through a submit.
function handleLinkKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
applyLink();
} else if (event.key === "Escape") {
event.preventDefault();
closeLinkPopover();
}
}
function syncToolbar(instance: Editor) {
toolbar.bold = instance.isActive("bold");
toolbar.italic = instance.isActive("italic");
toolbar.link = instance.isActive("link");
toolbar.bulletList = instance.isActive("bulletList");
toolbar.orderedList = instance.isActive("orderedList");
toolbar.blockquote = instance.isActive("blockquote");
toolbar.alignment =
ALIGNMENT_VALUES.find((alignment) => instance.isActive({ textAlign: alignment })) ??
"left";
toolbar.fontSize = instance.getAttributes("textStyle").fontSize ?? DEFAULT_FONT_SIZE;
const level = HEADING_LEVELS.find((level) => instance.isActive("heading", { level }));
toolbar.textStyle = level ? `${level}` : "paragraph";
toolbar.characters = instance.storage.characterCount.characters();
}
@@ -227,11 +360,32 @@
$effect(() => {
editor?.setOptions({ editorProps: { attributes: editorAttributes } });
});
// A click outside the popover (e.g. back in the editor) moves the selection off the link, so
// the popover would be left editing a link that is no longer selected: close it instead. The
// link button is excluded because its own click already toggles the popover.
$effect(() => {
if (!showLinkPopover) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Element;
if (linkPopoverElement?.contains(target) || target.closest?.('[data-tool="link"]')) {
return;
}
showLinkPopover = false;
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
{#snippet toolbarButton({ labelKey, active, run, align, glyph, image }: ToolbarButton)}
{#snippet toolbarButton({ id, labelKey, active, run, align, glyph, icon: Icon }: ToolbarButton)}
<button
type="button"
data-tool={id}
onclick={run}
class={twJoin(
"flex size-10 cursor-pointer items-center justify-center rounded-lg border bg-white shadow-sm",
@@ -250,31 +404,32 @@
/>
{:else if glyph}
<span class={glyph.class}>{glyph.text}</span>
{:else if image}
<Image width="24" height="24" class="text-content" />
{:else if Icon}
<Icon width="24" height="24" class={active ? "text-secondary" : "text-content"} />
{/if}
</button>
{/snippet}
<div class={twMerge("space-y-4", className)}>
<div
class="flex items-center justify-between"
class="relative flex flex-wrap items-center justify-between gap-2"
role="toolbar"
aria-label={$t("domain.richTextEditor.toolbar")}
>
<div class="flex items-center gap-2">
{#if showFontSize}
<div class="flex flex-wrap items-center gap-2">
{#if showTextStyle}
<div class="relative flex">
<select
value={toolbar.fontSize}
onchange={(event) =>
editor?.chain().focus().setFontSize(event.currentTarget.value).run()}
aria-label={$t("domain.richTextEditor.fontSize")}
title={$t("domain.richTextEditor.fontSize")}
class="border-grey text-secondary flex h-10 w-auto max-w-27.5 cursor-pointer appearance-none items-center justify-center rounded-lg border bg-white bg-none px-2 py-1 pr-8 text-sm shadow-sm ring-0"
value={toolbar.textStyle}
onchange={(event) => setTextStyle(event.currentTarget.value as TextStyle)}
aria-label={$t("domain.richTextEditor.textStyle")}
title={$t("domain.richTextEditor.textStyle")}
class="border-grey text-secondary flex h-10 w-auto cursor-pointer appearance-none items-center justify-center rounded-lg border bg-white bg-none px-2 py-1 pr-8 text-sm shadow-sm ring-0"
>
{#each FONT_SIZES as size (size)}
<option value={size}>{size}</option>
{#each TEXT_STYLES as { value: style, labelKey, level } (style)}
<option value={style}
>{level ? $t(labelKey, { level }) : $t(labelKey)}</option
>
{/each}
</select>
<Chevron
@@ -298,6 +453,35 @@
{/each}
</div>
{/if}
{#if showLink && showLinkPopover}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={linkPopoverElement}
onkeydown={handleLinkKeydown}
class="border-grey absolute top-full left-0 z-10 mt-2 flex w-full max-w-md flex-col gap-3 rounded-lg border bg-white p-3 shadow-sm"
>
<TextInput
type="url"
bind:value={linkUrl}
labelText={$t("domain.richTextEditor.linkUrl")}
placeholder="https://"
/>
<div class="flex flex-wrap justify-end gap-2">
<Button size="sm" kind="ghost" onclick={closeLinkPopover}>
{$t("domain.richTextEditor.linkCancel")}
</Button>
{#if toolbar.link}
<Button size="sm" kind="secondary" onclick={removeLink}>
{$t("domain.richTextEditor.linkRemove")}
</Button>
{/if}
<Button size="sm" onclick={applyLink}>
{$t("domain.richTextEditor.linkApply")}
</Button>
</div>
</div>
{/if}
</div>
<div class="relative">
@@ -358,11 +542,69 @@
margin-bottom: 0;
}
:global(.tiptap h1),
:global(.tiptap h2),
:global(.tiptap h3),
:global(.tiptap h4) {
font-weight: var(--font-weight-bold);
color: var(--color-black);
margin-bottom: var(--text-lg);
}
:global(.tiptap h1) {
font-size: var(--text-3xl);
line-height: var(--text-3xl--line-height);
}
:global(.tiptap h2) {
font-size: var(--text-2xl);
line-height: var(--text-2xl--line-height);
}
:global(.tiptap h3) {
font-size: var(--text-xl);
line-height: var(--text-xl--line-height);
}
:global(.tiptap h4) {
font-size: var(--text-lg);
line-height: var(--text-lg--line-height);
}
:global(.tiptap strong) {
font-weight: var(--font-weight-bold);
color: var(--color-black);
}
:global(.tiptap a) {
color: var(--color-secondary);
text-decoration: underline;
}
:global(.tiptap ul),
:global(.tiptap ol) {
padding-left: var(--text-xl);
margin-bottom: var(--text-lg);
}
:global(.tiptap ul) {
list-style: disc;
}
:global(.tiptap ol) {
list-style: decimal;
}
:global(.tiptap li p) {
margin-bottom: 0;
}
:global(.tiptap blockquote) {
border-left: 4px solid var(--color-primary);
padding-left: var(--text-lg);
margin-bottom: var(--text-lg);
}
:global(.tiptap img) {
display: block;
max-width: 100%;
@@ -14,6 +14,7 @@
extractTerritory,
type NominatimResult,
} from "../../../services/nominatim";
import { debounce } from "../../../utils/debounce";
import DropdownMenu from "../dropdown/DropdownMenu.svelte";
import type { Territory } from "../../../openapi/client";
@@ -25,6 +26,7 @@
interface Props {
class?: ClassNameValue;
searchClasses?: ClassNameValue;
value?: string;
placeholder?: string;
helperText?: string;
@@ -38,6 +40,7 @@
let {
class: classes = undefined,
searchClasses = undefined,
value = $bindable(""),
placeholder,
helperText,
@@ -52,8 +55,6 @@
let options: TerritoryOption[] = $state([]);
let selected: TerritoryOption[] = $state([]);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let lastIncoming = "";
$effect(() => {
@@ -90,25 +91,26 @@
})();
});
function handleSearch(searchText: string) {
clearTimeout(searchTimer);
const searchPlaces = debounce(async (searchText: string) => {
const selectedIds = new Set(selected.map((s) => s.id));
const results = await searchPlace(searchText, 6);
options = results.map((result) => ({
id: result.osm_id.toString(),
label: result.display_name,
selected: selectedIds.has(result.osm_id.toString()),
result,
}));
});
function handleSearch(searchText: string) {
if (!searchText || searchText.length < 2) {
searchPlaces.cancel();
options = multiple ? [...selected] : [];
return;
}
searchTimer = setTimeout(async () => {
const selectedIds = new Set(selected.map((s) => s.id));
const results = await searchPlace(searchText, 6);
options = results.map((result) => ({
id: result.osm_id.toString(),
label: result.display_name,
selected: selectedIds.has(result.osm_id.toString()),
result,
}));
}, 300);
searchPlaces(searchText);
}
function handleChange(option: DropdownOption) {
@@ -168,7 +170,7 @@
class={multiple ? "border" : undefined}
variant={multiple ? "multiselect" : "basic"}
hasSearch
searchClasses={error && "border-tertiary border"}
searchClasses={twMerge(searchClasses, error && "border-tertiary border")}
singleSelect={!multiple}
clearable={!multiple}
bind:searchValue={value}
+5 -3
View File
@@ -7,7 +7,10 @@
import type { HTMLButtonAttributes } from "svelte/elements";
interface Props extends Omit<HTMLButtonAttributes, "class"> {
interface Props extends Omit<HTMLButtonAttributes, "class" | "value"> {
/** Current state, `true` for on */
value?: boolean;
/**
* Callback function that receives the new value of the state
* @param value `true` for on, `false` for off
@@ -30,6 +33,7 @@
}
let {
value = $bindable(false),
onChange,
btnClass,
btnClassOn,
@@ -40,8 +44,6 @@
...rest
}: Props = $props();
let value = $state(false);
function handleToggle() {
value = !value;
@@ -46,7 +46,7 @@
const wrapperClasses = $derived(twMerge("relative w-full", classes));
const navButtonClasses: ClassNameValue = $derived(
twMerge(
"bg-variant1 absolute z-10 hidden h-10 w-10 rounded-full p-2 shadow-md disabled:opacity-50 lg:block",
"bg-purple-med absolute z-10 hidden h-10 w-10 rounded-full p-2 shadow-md disabled:opacity-50 lg:block",
centerNavButtons ? "-translate-y-1/2" : "",
),
);
@@ -26,7 +26,9 @@
}: Props = $props();
const widgetUrl = $derived(
variant === "project" ? `https://www.goteo.org/widget/project/${projectSlug}` : "",
variant === "project" && typeof window !== "undefined"
? `${window.location.origin}/widget/project/${projectSlug}`
: "",
);
let openModal = $state(false);
@@ -74,7 +76,7 @@
<CopyUrl {url} />
<Facebook {url} />
<X text={shareText} {url} />
{#if variant === "project"}
{#if widgetUrl}
<Iframe url={widgetUrl} />
{/if}
</div>
+2 -2
View File
@@ -14,14 +14,14 @@
const variantStyles = {
success: "bg-variant2",
warning: "bg-variant4",
error: "bg-tertiary",
error: "bg-tertiary text-white",
bold: "bg-grey border-grey text-secondary font-bold border-purple-soft",
};
</script>
<div
class={twMerge(
"text-body-small flex h-fit items-center justify-center gap-2 rounded-sm border border-black bg-white px-2 py-1 whitespace-nowrap text-black",
"text-body-small flex h-fit items-center justify-center gap-2 rounded-sm bg-white px-2 py-1 whitespace-nowrap text-black",
variant ? variantStyles[variant] : "",
classes,
)}
+2
View File
@@ -179,6 +179,8 @@
</div>
{/if}
</div>
{:else if noVideoSrc && previewImage}
<img src={previewImage} alt={poster.alt} class="h-full w-full rounded-lg object-cover" />
{:else if noVideoSrc}
<div
class="flex h-full items-center justify-center rounded-lg bg-gray-100 p-4 text-sm text-gray-600"
+15 -506
View File
@@ -1,524 +1,33 @@
<script lang="ts">
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<ActivityData | undefined>(undefined);
let matchfundingData = $state<MatchfundingCardData | undefined>(undefined);
let loading = $state(true);
let error = $state<string | null>(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<ProjectSupport>(supportsResponse);
const projects = toCollectionItems<Project>(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<MatchCall>(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<string> 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();
}
});
</script>
<Grid class="grid-cols-1 gap-6 lg:grid-cols-{matchfundingData ? '3' : '2'}">
{#if loading}
<!-- Loading state -->
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-content">Loading...</p>
</div>
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-content">Loading...</p>
</div>
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-content">Loading...</p>
</div>
{:else if error}
<!-- Error state -->
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-tertiary font-semibold">{error}</p>
</div>
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-tertiary font-semibold">{error}</p>
</div>
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-tertiary font-semibold">{error}</p>
</div>
{:else}
<!-- Activity cards -->
<DonationsCard {lang} data={activityData} />
<ProjectsCard {lang} data={activityData} />
{#if matchfundingData}
<MatchfundingCard {lang} data={matchfundingData} />
{/if}
{/if}
<Grid
class={hasMatchfunding
? "grid-cols-1 gap-6 lg:grid-cols-3"
: "grid-cols-1 gap-6 lg:grid-cols-2"}
>
<DonationsCard {period} {user} />
<ProjectsCard {period} {user} />
<div class={hasMatchfunding ? "" : "hidden"}>
<MatchfundingCard {user} onHasData={onMatchfundingDataChange} />
</div>
</Grid>
@@ -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(),
+208 -56
View File
@@ -1,80 +1,232 @@
<script lang="ts">
import BaseActivityCard from "./BaseActivityCard.svelte";
import { locale, t } from "../../i18n/store";
import {
apiProjectSupportsGetCollection,
apiProjectSupportsmoneyTotalGetCollection,
apiProjectsGetCollection,
} from "../../openapi/client/sdk.gen.ts";
import { getDefaultCurrency } from "../../utils/consts";
import { formatAmountWithSymbol } from "../../utils/currencies";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import LoadingSpinner from "../search/LoadingSpinner.svelte";
import type { ActivityData } from "../../types/me-page";
import type { Project, ProjectSupport, User } from "../../openapi/client/types.gen.ts";
import type { DonationsSummary } from "../../types/me-page";
interface Props {
/**
* Current language locale
* Filter period (e.g. "2025"). Only used to re-fetch on change.
*/
lang: string;
period?: string;
/**
* Optional activity data for filled state
* If undefined/null, shows empty state
* Authenticated user whose donations are listed
*/
data?: ActivityData;
user: User;
}
let { lang, data }: Props = $props();
let { period, user }: Props = $props();
const fallbackProjectTitle = $derived(lang === "es" ? "Proyecto" : "Project");
let donations = $state<Promise<DonationsSummary | undefined>>();
// Determine if this card has data
const hasData = $derived(!!(data?.donations && data.donations.count > 0));
type ProjectSupportsCollection = {
member?: ProjectSupport[];
"hydra:member"?: ProjectSupport[];
totalItems?: number;
"hydra:totalItems"?: number;
};
// Get donations data
const donationsData = $derived(data?.donations);
async function fetchDonations(): Promise<DonationsSummary> {
// Fetch user's donations - using accounting IRI as origin.
// project_supports is NOT a localized resource, so no Accept-Language header is sent.
// Use JSON-LD to get the real total count (only recent items are needed, so a tiny page).
const { data: supportsResponse, error: supportsError } =
await apiProjectSupportsGetCollection({
baseUrl: "/api/relay",
query: {
origin: user.accounting,
itemsPerPage: 3,
},
headers: { Accept: "application/ld+json" },
});
// Formatted total
const formattedTotal = $derived(
donationsData?.total
? formatAmountWithSymbol(donationsData.total.amount, donationsData.total.currency, lang)
: "",
);
if (supportsError) {
console.error("Failed to fetch contributions:", supportsError);
throw new Error("Failed to load donation data");
}
const collection = (supportsResponse as unknown as ProjectSupportsCollection) ?? {};
const contributions = toCollectionItems<ProjectSupport>(collection);
// JSON-LD metadata gives the real total, independent of the page size.
const contributionsCount =
collection.totalItems ?? collection["hydra:totalItems"] ?? contributions.length;
// Total money comes from the dedicated endpoint (there is no manual calculation,
// since summing the capped "recent" page would be wrong).
const { data: totalMoney, error: totalMoneyError } =
await apiProjectSupportsmoneyTotalGetCollection({
baseUrl: "/api/relay",
query: {
origin: user.accounting,
},
});
if (totalMoneyError) {
console.warn("Total money endpoint returned error:", {
error: totalMoneyError,
user,
});
}
// Resolve project details with a single batched request by slug/id,
// instead of one GET per project.
const idOrSlugs = Array.from(
new Set(
contributions
.map((support) =>
extractId(
typeof support.project === "string" ? support.project : undefined,
),
)
.filter((idOrSlug): idOrSlug is string => Boolean(idOrSlug)),
),
);
const projectByIdOrSlug = new Map<string, Project>();
if (idOrSlugs.length > 0) {
const { data: projectsResponse, error: projectsError } = await apiProjectsGetCollection(
{
baseUrl: "/api/relay",
query: {
"slug[]": idOrSlugs,
},
// Projects ARE localized, so force the current language here.
headers: { "Accept-Language": $locale },
},
);
if (projectsError) {
console.warn("Failed to fetch projects for donations:", projectsError);
} else {
for (const project of toCollectionItems<Project>(projectsResponse)) {
if (project.slug) {
projectByIdOrSlug.set(project.slug, project);
}
if (project.id) {
projectByIdOrSlug.set(String(project.id), project);
}
}
}
}
const recentDonations = contributions.slice(0, 3).map((support) => {
const idOrSlug = extractId(
typeof support.project === "string" ? support.project : undefined,
);
const project = idOrSlug ? projectByIdOrSlug.get(idOrSlug) : undefined;
// Use the slug for URLs, never the ID. Unless the slug is missing/invalid,
// in which case the UI will handle the empty value gracefully.
const projectSlug = project?.slug && isNaN(Number(project.slug)) ? project.slug : "";
return {
id: support.id?.toString() || idOrSlug || "",
amount: support.money || { amount: 0, currency: getDefaultCurrency() },
projectTitle: project?.title || "",
projectSlug,
};
});
return {
count: contributionsCount,
total: {
amount: totalMoney?.amount ?? 0,
currency: totalMoney?.currency ?? getDefaultCurrency(),
},
recentDonations,
};
}
// Fetch on mount and re-fetch when the period changes
$effect(() => {
if (period) {
donations = fetchDonations();
}
});
</script>
{#snippet loadingShell()}
<div class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white">
<div class="flex items-center gap-2">
<LoadingSpinner />
<p class="text-content">{$t("system.loading")}</p>
</div>
</div>
{/snippet}
{#snippet errorShell(message: unknown)}
<div class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white">
<p class="text-tertiary font-semibold">{message}</p>
</div>
{/snippet}
<!-- GOTEO-OC-DONATION-CERTIFICATE: secondary action "Solicitar certificado" is exclusive to the
donation-certificate feature of Goteo under Fundación Platoniq, hidden in the open-core.
Re-enable when a feature toggle exists. Do not delete.
<BaseActivityCard ... secondaryActionLabel="pages.me.donations.certificate"
secondaryActionHref="#" ... /> -->
<BaseActivityCard
titleKey="pages.me.donations.title"
leftStatLabel="pages.me.donations.count"
leftStatValue={donationsData?.count ?? 0}
rightStatLabel="pages.me.donations.total"
rightStatValue={formattedTotal}
recentTitleKey="pages.me.donations.recent"
illustrationPath="/images/profile/ilustration-donations.png"
primaryActionLabel="pages.me.donations.viewAll"
primaryActionHref="/me#donated-projects"
isEmpty={!hasData}
emptyMessageKey="pages.me.donations.empty"
emptyCtaLabel="pages.me.donations.explore"
emptyCtaLink="/search?status[]=in_campaign"
>
{#if donationsData?.recentDonations}
{#each donationsData.recentDonations.slice(0, 2) as donation}
<li class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-black">
{formatAmountWithSymbol(donation.amount.amount, donation.amount.currency, lang)}
</span>
<span class="text-sm font-semibold text-black"> - </span>
{#if donation.projectSlug}
<a
href={`/project/${donation.projectSlug}`}
class="text-secondary text-sm no-underline hover:underline focus:underline focus:outline-none"
>
{donation.projectTitle || fallbackProjectTitle}
</a>
{:else}
<span class="text-tertiary text-sm italic">
{donation.projectTitle || fallbackProjectTitle}
</span>
{/if}
</li>
{/each}
{/if}
</BaseActivityCard>
{#if donations}
{#await donations}
{@render loadingShell()}
{:then summary}
<BaseActivityCard
titleKey="pages.me.donations.title"
leftStatLabel="pages.me.donations.count"
leftStatValue={summary?.count ?? 0}
rightStatLabel="pages.me.donations.total"
rightStatValue={summary?.total
? formatAmountWithSymbol(summary.total.amount, summary.total.currency, $locale)
: ""}
recentTitleKey="pages.me.donations.recent"
illustrationPath="/images/profile/ilustration-donations.png"
primaryActionLabel="pages.me.donations.viewAll"
primaryActionHref="/me#donated-projects"
isEmpty={!summary || summary.count === 0}
emptyMessageKey="pages.me.donations.empty"
emptyCtaLabel="pages.me.donations.explore"
emptyCtaLink="/search?status[]=in_campaign"
>
{#if summary?.recentDonations}
{#each summary.recentDonations.slice(0, 2) as donation}
<li class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-black">
{formatAmountWithSymbol(
donation.amount.amount,
donation.amount.currency,
$locale,
)}
</span>
<span class="text-sm font-semibold text-black"> - </span>
{#if donation.projectSlug}
<a
href={`/project/${donation.projectSlug}`}
class="text-secondary text-sm no-underline hover:underline focus:underline focus:outline-none"
>
{donation.projectTitle}
</a>
{:else}
<span class="text-tertiary text-sm italic">
{donation.projectTitle}
</span>
{/if}
</li>
{/each}
{/if}
</BaseActivityCard>
{:catch donationsError}
{@render errorShell(donationsError)}
{/await}
{:else}
{@render loadingShell()}
{/if}
@@ -0,0 +1,107 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import MotivatingProjectsSection from "./manage/MotivatingProjectsSection.svelte";
import { languagesList } from "../../i18n/locales";
import { locale, t } from "../../i18n/store";
import { getCookie, setCookie } from "../../utils/cookies";
import ActionableButton from "../library/buttons/ActionableButton.svelte";
import Toast from "../library/feedback/Toast.svelte";
import Select from "../library/inputs/Select.svelte";
import Title from "../library/typography/Title.svelte";
import type { Category } from "../../openapi/client";
interface Props {
/** Categories to offer, read by the page from `apiCategoriesGetCollection` */
categories?: Category[];
class?: ClassNameValue;
}
let { categories = [], class: classes = "" }: Props = $props();
let language: string = $state($locale ?? "es");
let toast = $state(false);
let motivatingProjects: Category[] = $state([]);
const CATEGORIES_COOKIE = "preferred-categories";
$effect(() => {
const stored = getCookie(CATEGORIES_COOKIE);
if (!stored || motivatingProjects.length > 0 || categories.length === 0) {
return;
}
try {
const ids = JSON.parse(stored) as (string | number)[];
motivatingProjects = categories.filter((category) =>
ids.some((id) => String(id) === String(category.id)),
);
} catch {
motivatingProjects = [];
}
});
/* The preferred language is the only preference this screen owns, and the API has no
endpoint for it: it lives in a cookie, read back by `src/middleware/index.ts`. */
async function save() {
setCookie("preferred-lang", language);
const ids = motivatingProjects
.map((category) => category.id)
.filter((id): id is string => id !== undefined && id !== null)
.map((id) => String(id));
setCookie(CATEGORIES_COOKIE, JSON.stringify(ids));
toast = true;
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
save();
}
</script>
<form onsubmit={handleSubmit} class={twMerge("flex flex-col gap-8", classes)}>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="font-body text-[2.5rem] leading-12 font-bold text-black">
{$t("pages.me.manage.preferences.title")}
</h1>
<p class="text-content font-body text-base leading-6">
{$t("pages.me.manage.preferences.subtitle")}
</p>
</div>
<ActionableButton action={save} autoreset={3000} class="w-fit">
{$t("pages.me.manage.preferences.save")}
</ActionableButton>
</div>
<section class="flex flex-col gap-3">
<Title level={2} variant="subsection">
{$t("pages.me.manage.preferences.languages.title")}
</Title>
<div class="mt-2 flex max-w-2xl flex-col gap-4">
<div class="w-full">
<Select
name="language"
labelText={$t("pages.me.manage.preferences.languages.label")}
helperText={$t("pages.me.manage.preferences.languages.helper")}
bind:value={language}
>
{#each Object.entries(languagesList) as [code, name] (code)}
<option value={code}>{name}</option>
{/each}
</Select>
</div>
</div>
</section>
<MotivatingProjectsSection options={categories} bind:selected={motivatingProjects} />
</form>
<Toast bind:showToast={toast} variant="success">
{$t("pages.me.manage.preferences.toast.saved")}
</Toast>
+211 -43
View File
@@ -1,61 +1,229 @@
<script lang="ts">
import BaseActivityCard from "./BaseActivityCard.svelte";
import { locale, t } from "../../i18n/store";
import {
apiAccountingsIdGet,
apiMatchCallsGetCollection,
} from "../../openapi/client/sdk.gen.ts";
import { getDefaultCurrency } from "../../utils/consts";
import { formatAmountWithSymbol } from "../../utils/currencies";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import { sumMoney } from "../../utils/money";
import LoadingSpinner from "../search/LoadingSpinner.svelte";
import type { MatchCall, User } from "../../openapi/client/types.gen.ts";
import type { MatchfundingCardData } from "../../types/me-page";
interface Props {
/**
* Current language locale
* Authenticated user whose matchfunding calls are listed
*/
lang: string;
user: User;
/**
* Optional matchfunding data for filled state
* If undefined/null, card is not shown
* Notifies the parent whether the card has data, so the layout can
* decide how many grid columns to use.
*/
data?: MatchfundingCardData;
onHasData?: (hasData: boolean) => void;
}
let { lang, data }: Props = $props();
let { user, onHasData }: Props = $props();
const hasData = $derived(!!(data && data.totalCalls > 0));
const formattedTotal = $derived(
data?.totalDonated
? formatAmountWithSymbol(data.totalDonated.amount, data.totalDonated.currency, lang)
: "",
);
let data = $state<Promise<MatchfundingCardData | undefined>>();
const MATCH_CALLS_PER_PAGE = 30;
type MatchCallsCollection = {
member?: MatchCall[];
"hydra:member"?: MatchCall[];
totalItems?: number;
"hydra:totalItems"?: number;
};
async function fetchMatchfunding(): Promise<MatchfundingCardData> {
const headers = {
"Accept-Language": $locale,
};
// Fetch all match calls for this user (filtered by manager server-side),
// paging through with JSON-LD to get the real total, since all of them are
// needed to sum their accountings.
const allCalls: MatchCall[] = [];
let currentPage = 1;
let totalItems = 0;
do {
const { data: callsData, error: callsError } = await apiMatchCallsGetCollection({
baseUrl: "/api/relay",
query: {
"managers.id": user.id,
page: currentPage,
itemsPerPage: MATCH_CALLS_PER_PAGE,
} as any,
headers: {
Accept: "application/ld+json",
...headers,
},
});
if (callsError) {
console.warn("Failed to fetch matchfunding calls:", callsError);
throw callsError;
}
const collection = (callsData as unknown as MatchCallsCollection) ?? {};
allCalls.push(...toCollectionItems<MatchCall>(collection));
totalItems = collection.totalItems ?? collection["hydra:totalItems"] ?? allCalls.length;
currentPage++;
} while (allCalls.length < totalItems);
const calls = allCalls;
if (calls.length === 0) {
return {
totalCalls: 0,
totalDonated: { amount: 0, currency: getDefaultCurrency() },
recentCalls: [],
};
}
// Fetch accounting data for each call to get donation amounts
const callAccountings = await Promise.all(
calls.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 = calls.slice(0, 3).map((call, index) => ({
id: call.id || 0,
title: call.title || "",
donationAmount: {
amount: callAccountings[index]?.amount || 0,
currency: callAccountings[index]?.currency || getDefaultCurrency(),
},
}));
return {
totalCalls: calls.length,
totalDonated: {
amount: totalDonated,
currency,
},
recentCalls,
};
}
$effect(() => {
data = fetchMatchfunding();
});
// Notify the parent whether the card rendered data, so the grid columns can adapt
$effect(() => {
data?.then((summary) => {
onHasData?.(!!(summary && summary.totalCalls > 0));
});
});
</script>
{#if hasData}
<BaseActivityCard
titleKey="pages.me.matchfunding.card.title"
leftStatLabel="pages.me.matchfunding.card.calls"
leftStatValue={data?.totalCalls ?? 0}
rightStatLabel="pages.me.matchfunding.card.donated"
rightStatValue={formattedTotal}
recentTitleKey="pages.me.matchfunding.card.recent"
illustrationPath="/images/profile/ilustration-matchfunding.png"
primaryActionLabel="pages.me.matchfunding.card.viewAll"
primaryActionHref="/me#matchfunding"
isEmpty={false}
>
{#if data?.recentCalls}
{#each data.recentCalls.slice(0, 2) as call}
<li class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-black">
{formatAmountWithSymbol(
call.donationAmount.amount,
call.donationAmount.currency,
lang,
)}
</span>
<span class="text-sm font-semibold text-black"> - </span>
<span class="text-content text-sm">
{call.title}
</span>
</li>
{/each}
{/if}
</BaseActivityCard>
{#if data}
{#await data}
<!-- Loading state -->
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<div class="flex items-center gap-2">
<LoadingSpinner />
<p class="text-content">{$t("system.loading")}</p>
</div>
</div>
{:then summary}
<BaseActivityCard
titleKey="pages.me.matchfunding.card.title"
leftStatLabel="pages.me.matchfunding.card.calls"
leftStatValue={summary?.totalCalls ?? 0}
rightStatLabel="pages.me.matchfunding.card.donated"
rightStatValue={summary?.totalDonated
? formatAmountWithSymbol(
summary.totalDonated.amount,
summary.totalDonated.currency,
$locale,
)
: ""}
recentTitleKey="pages.me.matchfunding.card.recent"
illustrationPath="/images/profile/ilustration-matchfunding.png"
primaryActionLabel="pages.me.matchfunding.card.viewAll"
primaryActionHref="/me#matchfunding"
isEmpty={false}
>
{#if summary?.recentCalls}
{#each summary.recentCalls.slice(0, 2) as call}
<li class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-black">
{formatAmountWithSymbol(
call.donationAmount.amount,
call.donationAmount.currency,
$locale,
)}
</span>
<span class="text-sm font-semibold text-black"> - </span>
<span class="text-content text-sm">
{call.title}
</span>
</li>
{/each}
{/if}
</BaseActivityCard>
{:catch matchfundingError}
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-tertiary font-semibold">{matchfundingError.message}</p>
</div>
{/await}
{:else}
<!-- Loading state -->
<div class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white">
<div class="flex items-center gap-2">
<LoadingSpinner />
<p class="text-content">{$t("system.loading")}</p>
</div>
</div>
{/if}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import { ME_SECTIONS } from "./meSections";
import { locale, t } from "../../i18n/store";
interface Props {
class?: ClassNameValue;
}
let { class: classes = "" }: Props = $props();
let pathname = $state("");
$effect(() => {
pathname = window.location.pathname.replace(/\/+$/, "");
});
function isActive(href: string, exact?: boolean): boolean {
return [href, `/${$locale}${href}`].some((target) =>
exact ? pathname === target : pathname === target || pathname.startsWith(target + "/"),
);
}
</script>
<nav class={twMerge("touch-pan-x overflow-x-auto px-2 pb-3 md:px-4 md:pb-4", classes)}>
<ul class="flex min-w-max items-center gap-2">
{#each ME_SECTIONS as section (section.href)}
{@const active = isActive(section.href, section.exact)}
<li>
<a
href="/{$locale}{section.href}"
aria-current={active ? "page" : undefined}
class="block rounded-lg px-4 py-2 whitespace-nowrap transition-colors duration-200 {active
? 'bg-secondary text-white'
: 'bg-grey text-secondary'}"
>
{$t(section.labelKey)}
</a>
</li>
{/each}
</ul>
</nav>
@@ -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[];
@@ -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")}
</p>
<p class="text-base leading-normal">
{$t("pages.profile.donorType.fan.description")}
{$t("pages.profile.donorType.fan.description", { amount: formattedMoney })}
</p>
</div>
<div class="size-25">
+20 -13
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import FacebookIcon from "../icons/social/FacebookIcon.svelte";
import GmailIcon from "../icons/social/GmailIcon.svelte";
import InstagramIcon from "../icons/social/InstagramIcon.svelte";
import LinkedinIcon from "../icons/social/LinkedinIcon.svelte";
import Facebook from "../icons/social/Facebook.svelte";
import Gmail from "../icons/social/Gmail.svelte";
import Instagram from "../icons/social/Instagram.svelte";
import Linkedin from "../icons/social/Linkedin.svelte";
import MediumIcon from "../icons/social/MediumIcon.svelte";
import XIcon from "../icons/social/XIcon.svelte";
import X from "../icons/social/X.svelte";
import Title from "../library/typography/Title.svelte";
import TerritoryTag from "../project/TerritoryTag.svelte";
@@ -50,13 +50,14 @@
),
);
// Same order as the design
const allSocialLinks: Record<SocialLinkKey, SocialLink> = $derived({
email: { url: resolvedLinks.email || "", label: "Email", icon: GmailIcon },
facebook: { url: resolvedLinks.facebook || "", label: "Facebook", icon: FacebookIcon },
instagram: { url: resolvedLinks.instagram || "", label: "Instagram", icon: InstagramIcon },
linkedin: { url: resolvedLinks.linkedin || "", label: "LinkedIn", icon: LinkedinIcon },
twitter: { url: resolvedLinks.twitter || "", label: "X/Twitter", icon: X },
instagram: { url: resolvedLinks.instagram || "", label: "Instagram", icon: Instagram },
facebook: { url: resolvedLinks.facebook || "", label: "Facebook", icon: Facebook },
linkedin: { url: resolvedLinks.linkedin || "", label: "LinkedIn", icon: Linkedin },
email: { url: resolvedLinks.email || "", label: "Email", icon: Gmail },
medium: { url: resolvedLinks.medium || "", label: "Medium", icon: MediumIcon },
twitter: { url: resolvedLinks.twitter || "", label: "X/Twitter", icon: XIcon },
});
const socialMediaLinks = $derived(
@@ -80,15 +81,21 @@
<!-- Social Media Links -->
{#if socialMediaLinks.length > 0}
<div class="flex items-center gap-2">
{#each socialMediaLinks as [, link]}
{#each socialMediaLinks as [key, link]}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="bg-variant1 text-secondary focus:ring-secondary flex size-6 items-center justify-center rounded p-1 transition-opacity hover:opacity-90 focus:ring-2 focus:outline-none"
class="text-secondary focus:ring-secondary flex size-6 items-center justify-center overflow-hidden rounded-[4.8px] transition-opacity hover:opacity-90 focus:ring-2 focus:outline-none"
aria-label={link.label}
>
<link.icon class="size-full" />
{#if key === "medium"}
<span class="bg-variant1 flex size-full p-1">
<link.icon class="size-full" />
</span>
{:else}
<link.icon width="24" height="24" />
{/if}
</a>
{/each}
</div>
+211 -57
View File
@@ -1,75 +1,229 @@
<script lang="ts">
import BaseActivityCard from "./BaseActivityCard.svelte";
import { locale, t } from "../../i18n/store";
import { client } from "../../openapi/client/client.gen.ts";
import { apiUsersIdOrHandleGetUrl } from "../../openapi/client/operation-paths.gen.ts";
import { apiAccountingsIdGet, apiProjectsGetCollection } from "../../openapi/client/sdk.gen.ts";
import { getDefaultCurrency } from "../../utils/consts";
import { formatAmountWithSymbol } from "../../utils/currencies";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import { sumMoney } from "../../utils/money";
import LoadingSpinner from "../search/LoadingSpinner.svelte";
import type { ActivityData } from "../../types/me-page";
import type { MoneyOutput, Project, User } from "../../openapi/client/types.gen.ts";
import type { ProjectStatus, ProjectsSummary } from "../../types/me-page";
interface Props {
/**
* Current language locale
* Filter period (e.g. "2025"). Only used to re-fetch on change.
*/
lang: string;
period?: string;
/**
* Optional activity data for filled state
* If undefined/null, shows empty state
* Authenticated user whose projects are listed
*/
data?: ActivityData;
user: User;
}
let { lang, data }: Props = $props();
let { period, user }: Props = $props();
// Determine if this card has data
const hasData = $derived(!!(data?.projects && data.projects.count > 0));
let projects = $state<Promise<ProjectsSummary | undefined>>();
// Get projects data
const projectsData = $derived(data?.projects);
type ProjectsCollection = {
member?: Project[];
"hydra:member"?: Project[];
totalItems?: number;
"hydra:totalItems"?: number;
};
// Formatted total raised
const formattedTotal = $derived(
projectsData?.totalRaised
? formatAmountWithSymbol(
projectsData.totalRaised.amount,
projectsData.totalRaised.currency,
lang,
)
: "",
);
async function fetchProjects(): Promise<ProjectsSummary> {
const headers = {
"Accept-Language": $locale,
};
// Fetch user's owned projects - using user IRI as owner.
// Use JSON-LD to get the real total count (only recent items are needed).
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: {
Accept: "application/ld+json",
...headers,
},
});
if (projectsError) {
console.error("Failed to fetch projects:", projectsError);
throw projectsError;
}
const collection = (projectsResponse as unknown as ProjectsCollection) ?? {};
const projects = toCollectionItems<Project>(collection);
// JSON-LD metadata gives the real total, independent of the page size.
const projectsCount =
collection.totalItems ?? collection["hydra:totalItems"] ?? projects.length;
// Sum the balance of each owned project's accounting
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();
}
}
// Map projects to recent projects
const recentProjects = projects.slice(0, 3).map((project) => {
// Use the slug for URLs, never the ID. Unless the slug is missing/invalid,
// in which case the UI will handle the empty value gracefully.
const projectSlug = project.slug && isNaN(Number(project.slug)) ? project.slug : "";
return {
id: project.id?.toString() || "",
title: project.title || "",
slug: projectSlug,
status: (project.status as ProjectStatus) || "in_draft",
createdAt: project.dateCreated || "",
};
});
return {
count: projectsCount,
totalRaised: {
amount: projectsTotalAmount,
currency: projectsTotalCurrency ?? getDefaultCurrency(),
},
recentProjects,
};
}
// Fetch on mount and re-fetch when the period changes
$effect(() => {
if (period) {
projects = fetchProjects();
}
});
</script>
<BaseActivityCard
titleKey="pages.me.projects.title"
leftStatLabel="pages.me.projects.count"
leftStatValue={projectsData?.count ?? 0}
rightStatLabel="pages.me.projects.raised"
rightStatValue={formattedTotal}
recentTitleKey="pages.me.projects.recent"
illustrationPath="/images/profile/ilustration-project.png"
primaryActionLabel="pages.me.projects.viewAll"
primaryActionHref="/me#owned-projects"
secondaryActionLabel="pages.me.projects.createNew"
secondaryActionHref="/create/project"
isEmpty={!hasData}
emptyMessageKey="pages.me.projects.empty"
emptyCtaLabel="pages.me.projects.create"
emptyCtaLink="/create/project"
>
{#if projectsData?.recentProjects}
{#each projectsData.recentProjects.slice(0, 2) as project}
<li class="flex items-start gap-2">
{#if project.slug}
<a
href={`/project/${project.slug}`}
class="text-content hover:text-secondary focus:text-secondary text-sm no-underline focus:outline-none"
>
{project.title}
</a>
{:else}
<span class="text-tertiary text-sm italic">
{project.title}
</span>
{/if}
</li>
{/each}
{/if}
</BaseActivityCard>
{#if projects}
{#await projects}
<!-- Loading state -->
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<div class="flex items-center gap-2">
<LoadingSpinner />
<p class="text-content">{$t("system.loading")}</p>
</div>
</div>
{:then summary}
<BaseActivityCard
titleKey="pages.me.projects.title"
leftStatLabel="pages.me.projects.count"
leftStatValue={summary?.count ?? 0}
rightStatLabel="pages.me.projects.raised"
rightStatValue={summary?.totalRaised
? formatAmountWithSymbol(
summary.totalRaised.amount,
summary.totalRaised.currency,
$locale,
)
: ""}
recentTitleKey="pages.me.projects.recent"
illustrationPath="/images/profile/ilustration-project.png"
primaryActionLabel="pages.me.projects.viewAll"
primaryActionHref="/me#owned-projects"
secondaryActionLabel="pages.me.projects.createNew"
secondaryActionHref="/create/project"
isEmpty={!summary || summary.count === 0}
emptyMessageKey="pages.me.projects.empty"
emptyCtaLabel="pages.me.projects.create"
emptyCtaLink="/create/project"
>
{#if summary?.recentProjects}
{#each summary.recentProjects.slice(0, 2) as project}
<li class="flex items-start gap-2">
{#if project.slug}
<a
href={`/project/${project.slug}`}
class="text-content hover:text-secondary focus:text-secondary text-sm no-underline focus:outline-none"
>
{project.title}
</a>
{:else}
<span class="text-tertiary text-sm italic">
{project.title}
</span>
{/if}
</li>
{/each}
{/if}
</BaseActivityCard>
{:catch projectsError}
<div
class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white"
>
<p class="text-tertiary font-semibold">{projectsError.message}</p>
</div>
{/await}
{:else}
<!-- Loading state -->
<div class="border-grey flex min-h-96 items-center justify-center rounded-4xl border bg-white">
<div class="flex items-center gap-2">
<LoadingSpinner />
<p class="text-content">{$t("system.loading")}</p>
</div>
</div>
{/if}
+21 -11
View File
@@ -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 @@
]);
</script>
{#snippet campaignGrid(list: Campaign[], emptyKey: string)}
{#if list.length > 0}
<Grid class="grid-cols-1 gap-6 md:grid-cols-2">
{#each list as campaign}
<CampaignCard size="small" {campaign} />
{/each}
</Grid>
{:else}
<p class="text-content text-center text-base">{$t(emptyKey)}</p>
{/if}
{/snippet}
<div class="flex w-full flex-col items-center">
<!-- Hero: cover + avatar + action buttons -->
<div class="bg-secondary relative h-100 w-full">
@@ -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}
<Grid class="grid-cols-1 gap-6 md:grid-cols-2">
{#each campaigns as campaign}
<CampaignCard size="small" {campaign} />
{/each}
</Grid>
{:else}
<p class="text-content text-center text-base">{$t("pages.profile.noProjects")}</p>
{/if}
{@render campaignGrid(ownedCampaigns, "pages.profile.noProjects")}
</div>
<!-- Tab: Donor type -->
<div data-tab-content="donorType" class="w-full" style="display:none">
<ProfileDonorType {projectsDonated} {moneyDonatedAmount} {moneyDonatedCurrency} />
<div class="mx-auto mt-10 min-h-10 w-full max-w-5xl px-4">
{@render campaignGrid(donatedCampaigns, "pages.profile.donorType.noProjects")}
</div>
</div>
</div>
@@ -0,0 +1,20 @@
<!--
FormNotice Component
Small helper note rendered at the bottom of a form section, pairing the info icon with a short
piece of text. Used for the legal/privacy copy that accompanies a group of fields.
-->
<script lang="ts">
import Warning from "../../icons/status/Warning.svelte";
interface Props {
text: string;
}
let { text }: Props = $props();
</script>
<div class="flex items-start gap-2">
<Warning width="16" height="16" class="text-content mt-1 shrink-0" />
<p class="text-content flex-1 text-sm leading-4 font-medium">{text}</p>
</div>

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