Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ca4596a4 | ||
|
|
84ab232c3b | ||
|
|
8e38e3440f | ||
|
|
3c469616ae | ||
|
|
619e63d07e | ||
|
|
cc7ea665ec | ||
|
|
b0e2db8908 | ||
|
|
208fff4e85 | ||
|
|
587dde7b9c | ||
|
|
02281f59d1 | ||
|
|
8c021b5806 | ||
|
|
935f183f76 | ||
|
|
888a954f68 | ||
|
|
6eeb6f06dd | ||
|
|
793683d5bb | ||
|
|
190f5275e6 | ||
|
|
18255969e0 | ||
|
|
71991431ff | ||
|
|
05e536899b | ||
|
|
34cadfbb26 | ||
|
|
d2345578cc | ||
|
|
69bcc612e7 | ||
|
|
c7d31a1f76 | ||
|
|
aeded636dc | ||
|
|
affc8af8d0 | ||
|
|
4b341047c6 | ||
|
|
f91c8403d6 | ||
|
|
557ff564e2 | ||
|
|
fb9a613614 | ||
|
|
8ab1f96c3c | ||
|
|
d326eec947 | ||
|
|
3294f0d8ea | ||
|
|
ab703eaed1 | ||
|
|
c4d1bd4c6e | ||
|
|
33d4866bdf | ||
|
|
1ea02dce87 | ||
|
|
dd1c5ed713 | ||
|
|
40e05989bb | ||
|
|
2047e58ed6 | ||
|
|
24d121a56a | ||
|
|
928595785d | ||
|
|
1e168faa08 | ||
|
|
3e709f8c98 | ||
|
|
2fd9d99bf7 | ||
|
|
d6db88be3b | ||
|
|
4c4be4d86e | ||
|
|
45b71d8be2 | ||
|
|
8fba3034d4 | ||
|
|
3141241b2d | ||
|
|
ddafe0dc7d | ||
|
|
6ccb2baff2 | ||
|
|
83de5412a0 | ||
|
|
21f06bba3d | ||
|
|
5881b75645 | ||
|
|
71c4cd050c | ||
|
|
aae796d3d1 | ||
|
|
4e558cf115 | ||
|
|
966ffd7d07 | ||
|
|
0f583d0fe1 | ||
|
|
9ea6139c27 | ||
|
|
ae8ed6aacb | ||
|
|
8f1fbf2768 | ||
|
|
08e98ca82a | ||
|
|
556c171ce9 | ||
|
|
c802acae6f | ||
|
|
f3271dc113 | ||
|
|
cb89e4bed1 | ||
|
|
9382f69585 | ||
|
|
292936a218 |
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"]`.
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 76 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
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,5 +1,5 @@
|
||||
import { z } from "astro/zod";
|
||||
import { ActionError, defineAction } from "astro:actions";
|
||||
import { z } from "zod";
|
||||
|
||||
import { bannerRepository } from "../repositories/banners";
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { z } from "astro/zod";
|
||||
import { ActionError, defineAction } from "astro:actions";
|
||||
import { z } from "zod";
|
||||
|
||||
import { highlightRepository } from "../repositories/highlights";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]" }} />
|
||||
@@ -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>
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||