From 292936a218df08ce4d3cc8ec6845c2241beedb2d Mon Sep 17 00:00:00 2001 From: Pep Villalonga Date: Wed, 23 Sep 2026 13:04:59 +0200 Subject: [PATCH 01/61] refactor: extract shared debounce util --- .../admin/charges/steps/StepUser.svelte | 9 +++--- .../library/filters/FilterComposerRow.svelte | 22 +++++++------- .../library/inputs/TerritoryInput.svelte | 30 +++++++++---------- src/utils/debounce.ts | 16 ++++++++++ 4 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 src/utils/debounce.ts diff --git a/src/components/admin/charges/steps/StepUser.svelte b/src/components/admin/charges/steps/StepUser.svelte index 6ff215eb..96dd4bc6 100644 --- a/src/components/admin/charges/steps/StepUser.svelte +++ b/src/components/admin/charges/steps/StepUser.svelte @@ -1,6 +1,7 @@ @@ -156,9 +163,9 @@ {$t("pages.admin.home.highlights.title")}

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

- +
@@ -200,7 +207,7 @@
{#each row as span}
@@ -230,7 +237,14 @@ number: String(index + 1).padStart(2, "0"), })}

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

{slot.projectTitle}

@@ -264,6 +278,8 @@
+{errorMessage} + ({ position: s.position, projectId: s.projectId })) : [], + slots: await Promise.all( + (data?.slots ?? []).map(async (s) => { + const { data: project } = await apiProjectsIdOrSlugGet({ + path: { idOrSlug: String(s.projectId) }, + headers: { "Accept-Language": lang }, + }); + + return { + position: s.position, + projectId: s.projectId, + projectTitle: project?.title ?? String(s.projectId), + projectImage: project?.video?.thumbnail ?? project?.cover ?? null, + }; + }), + ), }; --- -- 2.54.0 From f3271dc113b26b05284ebbc34d7aa0a496e7af69 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 24 Sep 2026 14:51:57 +0200 Subject: [PATCH 04/61] feat: update app config names to DTF --- README.md | 10 +++++----- db/seed.sql | 2 +- package.json | 12 +++++------- wrangler.toml | 40 +++++++++++----------------------------- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0df5d906..9b51ac0f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Goteo v4 Web +# DTF's Goteo v4 Web client -Frontend for the [Goteo](https://goteo.org) crowdfunding platform — built on Astro 7 + Svelte 5 + TailwindCSS 4, deployed on Cloudflare Workers. +Frontend for the [DTF](https://democratictech.fund) crowdfunding site — built on Astro 7 + Svelte 5 + TailwindCSS 4, deployed on Cloudflare Workers. -> **NOTE**: This application is a client of the [Goteo v4 API](https://github.com/goteo/org.goteo.api). You need a running v4 API instance and an OAuth client registered in it before this web app will work. +> **NOTE**: This application is a client of the [Goteo v4 API](https://github.com/goteo/v4-api). You need a running v4 API instance and an OAuth client registered in it before this web app will work. ## Installation @@ -11,8 +11,8 @@ This application requires [Node.js](https://nodejs.org/en/download) (v22.12 or l ### 1. Clone or download this repository. ```shell -git clone https://github.com/goteo/org.goteo.www -cd org.goteo.www +git clone https://git.coopcloud.tech/DemocraticTech.Fund/crowd-fund-web +cd crowd-fund-web ``` ### 2. Install the dependencies. diff --git a/db/seed.sql b/db/seed.sql index 331ae7ab..edae9f96 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -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' diff --git a/package.json b/package.json index 7f05534f..ad193be3 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,21 @@ { - "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", + "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": "pnpm check:format:prettier", "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 . ", diff --git a/wrangler.toml b/wrangler.toml index 3a93c98f..5e8b9def 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "goteov4web-app" +name = "dtfcrowd-app" main = "@astrojs/cloudflare/entrypoints/server" compatibility_date = "2025-06-26" compatibility_flags = ["nodejs_compat"] @@ -12,8 +12,8 @@ keep_vars = true [[d1_databases]] binding = "DB" -database_name = "goteov4web-db" -database_id = "1f119ee7-358a-4eba-b076-40248d4f28f6" +database_name = "dtfcrowd-db" +database_id = "6c3e6748-7e5c-450d-9565-d24739442341" migrations_dir = "db/migrations" @@ -26,39 +26,21 @@ directory = "./dist" enabled = true -# ============================================================================ -# TESTING -# ============================================================================ - -[env.testing] -name = "goteov4web-app-testing" - -routes = [ - { pattern = "testing.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, -] - -[[env.testing.d1_databases]] -binding = "DB" -database_name = "goteov4web-db-testing" -database_id = "5f54dc4e-811b-438b-b83e-9acee224f250" -migrations_dir = "db/migrations" - - # ============================================================================ # STAGING # ============================================================================ [env.staging] -name = "goteov4web-app-staging" +name = "dtfcrowd-app-staging" routes = [ - { pattern = "staging.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, + { pattern = "dtf-staging.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, ] [[env.staging.d1_databases]] binding = "DB" -database_name = "goteov4web-db-staging" -database_id = "b6afee51-6e91-4e25-b7c0-fb9b166b4cda" +database_name = "dtfcrowd-db-staging" +database_id = "9c338a5d-f860-488c-b0c0-46b5b74f0492" migrations_dir = "db/migrations" @@ -67,14 +49,14 @@ migrations_dir = "db/migrations" # ============================================================================ [env.prod] -name = "goteov4web-app-prod" +name = "dtfcrowd-app-prod" routes = [ - { pattern = "v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, + { pattern = "dtf.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, ] [[env.prod.d1_databases]] binding = "DB" -database_name = "goteov4web-db-prod" -database_id = "65fe9b71-9263-400e-ae7f-7d899493ee99" +database_name = "dtfcrowd-db-prod" +database_id = "b682c189-c6f2-4932-aae0-f08185efeba9" migrations_dir = "db/migrations" -- 2.54.0 From c802acae6fac2a1dd128313664260e309357e4c8 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 24 Sep 2026 14:58:03 +0200 Subject: [PATCH 05/61] feat: update colour palette --- src/styles/global.css | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index c7e234ec..27060a51 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -15,30 +15,30 @@ @theme { /* Light Theme (default) */ - --color-primary: #59e9d3; - --color-secondary: #462949; - --color-tertiary: #e94668; - --color-content: #575757; + --color-primary: #FF4D9A; + --color-secondary: #2EE6F6; + --color-tertiary: #A66BFF; + --color-content: #F2F2F7; /* Extended color tokens for components */ - --color-white: #fbfbfb; - --color-black: #3d3d3d; - --color-grey: #f3f3ef; - --color-purple-soft: #faf9ff; - --color-purple-med: #f6f5ff; + --color-white: #0F0F14; + --color-black: #FFFFFF; + --color-grey: #23262E; + --color-purple-soft: #171A22; + --color-purple-med: #211C36; /* Scrim tras diálogos y modales: gris cálido al 70% */ --color-overlay: #878282b2; - --color-variant1: #e6e5f7; - --color-variant2: #99ffcc; - --color-variant3: #72d8ff; - --color-variant4: #ffff99; + --color-variant1: #4A1E2B; + --color-variant2: #24D3A1; + --color-variant3: #2EE6F6; + --color-variant4: #FFC857; - --color-semantic-error: #e9b1bd; - --color-semantic-success: #b1e9e1; - --color-semantic-notification: #c2eeff; - --color-semantic-warning: #ffffc2; + --color-semantic-error: #FF6B6B; + --color-semantic-success: #24D3A1; + --color-semantic-notification: #183B46; + --color-semantic-warning: #FFC857; /* Declared by the `` component in Layout.astro; already carries its own fallbacks. */ --font-body: var(--font-karla); -- 2.54.0 From 556c171ce9e1d6082d7c8710c99d7e6e7a190d50 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 25 Sep 2026 11:55:16 +0200 Subject: [PATCH 06/61] fix: update usage of colors --- src/components/hero/Hero.svelte | 2 +- src/components/home/CampaignCard.svelte | 2 +- src/components/home/Matchfunding.astro | 12 +++---- src/components/home/Stats.astro | 36 ++++++++++---------- src/components/library/buttons/Button.svelte | 2 +- src/pages/[...locale]/index.astro | 6 ++-- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte index 6e2e7363..c691074e 100644 --- a/src/components/hero/Hero.svelte +++ b/src/components/hero/Hero.svelte @@ -26,7 +26,7 @@

{hero.title}

diff --git a/src/components/home/CampaignCard.svelte b/src/components/home/CampaignCard.svelte index 6800acb4..e3989279 100644 --- a/src/components/home/CampaignCard.svelte +++ b/src/components/home/CampaignCard.svelte @@ -177,7 +177,7 @@ Converted from CampaignCard.astro to maintain exact functionality {campaign.title} diff --git a/src/components/home/Matchfunding.astro b/src/components/home/Matchfunding.astro index 3e8fb600..3fe4bc0e 100644 --- a/src/components/home/Matchfunding.astro +++ b/src/components/home/Matchfunding.astro @@ -65,8 +65,8 @@ const calls: CallCard[] = [ <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 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"> @@ -82,7 +82,7 @@ const calls: CallCard[] = [ </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-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90" > {t("pages.home.matchfunding.actions.viewCalls")} </a> @@ -92,8 +92,8 @@ const calls: CallCard[] = [ <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"> + <div class="text-purple flex flex-col gap-4"> + <h3 class="text-purple text-4xl font-bold"> {t("pages.home.matchfunding.impact.title")} </h3> <p class="text-base leading-6 text-white"> @@ -137,7 +137,7 @@ const calls: CallCard[] = [ </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" + class="bg-purple-soft text-content w-fit rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90" > {card.cta} </a> diff --git a/src/components/home/Stats.astro b/src/components/home/Stats.astro index 112b12f1..cad28727 100644 --- a/src/components/home/Stats.astro +++ b/src/components/home/Stats.astro @@ -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> diff --git a/src/components/library/buttons/Button.svelte b/src/components/library/buttons/Button.svelte index f6d79ca8..5e674ace 100644 --- a/src/components/library/buttons/Button.svelte +++ b/src/components/library/buttons/Button.svelte @@ -40,7 +40,7 @@ const buttonClass = $derived( twMerge( - "text-secondary disabled:bg-grey flex w-auto items-center justify-center gap-2 font-bold transition hover:cursor-pointer", + "text-content disabled:bg-grey flex w-auto items-center justify-center gap-2 font-bold transition hover:cursor-pointer", sizeStyles[size], kindStyles[kind], href && "hover:underline", diff --git a/src/pages/[...locale]/index.astro b/src/pages/[...locale]/index.astro index a956c1cf..2782f081 100644 --- a/src/pages/[...locale]/index.astro +++ b/src/pages/[...locale]/index.astro @@ -36,13 +36,13 @@ const heroContent = hero ? await renderMarkdown(hero.content) : null; <Hero {hero} content={heroContent} /> <Banners banners={activeBanners} client:visible /> <Campaigns /> - <Blog /> + <!-- <Blog /> --> <Stats /> - <CampaignsTabs /> + <!-- <CampaignsTabs /> --> <Matchfunding /> <!-- Uncomment the matching import at the top when enabling either of these. --> <!-- <Stories client:load /> --> - <FeaturedPromoters /> + <!-- <FeaturedPromoters /> --> <!-- <Resources /> --> </App> </Layout> -- 2.54.0 From 08e98ca82ab06222af1343e26633877b234c359e Mon Sep 17 00:00:00 2001 From: Pep Villalonga <pep.villalonga@platoniq.net> Date: Fri, 25 Sep 2026 12:13:24 +0200 Subject: [PATCH 07/61] fix: show selection and empty state in basic dropdown --- .../library/dropdown/DropdownItem.svelte | 15 ++++-- .../library/dropdown/DropdownMenu.svelte | 49 +++++++++++-------- src/i18n/locales/es.json | 1 + 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/components/library/dropdown/DropdownItem.svelte b/src/components/library/dropdown/DropdownItem.svelte index 8b3d4d0d..216bc848 100644 --- a/src/components/library/dropdown/DropdownItem.svelte +++ b/src/components/library/dropdown/DropdownItem.svelte @@ -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,17 @@ </label> {:else if variant === "basic"} <button - class="w-full cursor-pointer p-4 text-base text-black" + type="button" + class={twJoin( + "flex w-full cursor-pointer items-center justify-between gap-2 p-4 text-start text-base text-black", + option.selected && "bg-purple-soft font-bold", + )} 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> diff --git a/src/components/library/dropdown/DropdownMenu.svelte b/src/components/library/dropdown/DropdownMenu.svelte index 97288b18..6086158e 100644 --- a/src/components/library/dropdown/DropdownMenu.svelte +++ b/src/components/library/dropdown/DropdownMenu.svelte @@ -101,6 +101,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,28 +206,36 @@ </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 - {variant} - option={item} - onChange={handleItemChange} - class={twJoin( - item.position === "start" && "rounded-t-lg", - item.position === "end" && "rounded-b-lg", - )} - /> - {/each} + <div + id={listId} + role="listbox" + aria-multiselectable={!singleSelect} + class="flex max-h-72 w-full flex-col overflow-y-auto" + > + {#each renderedItems as item} + <DropdownItem + {variant} + option={item} + onChange={handleItemChange} + class={twJoin( + item.position === "start" && "rounded-t-lg", + item.position === "end" && "rounded-b-lg", + )} + /> + {/each} + </div> </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> diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c642d7e6..e173494f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -332,6 +332,7 @@ "viewLess": "Ver menos", "share": "Compartir", "noData": "No hay datos disponibles", + "noOptions": "No hay opciones disponibles", "tooltip": { "copied": "¡Copiado!" }, -- 2.54.0 From ae8ed6aacba090fc7684d508b93807fd9b62aca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= <subiabrewd@gmail.com> Date: Fri, 25 Sep 2026 12:23:50 +0200 Subject: [PATCH 08/61] chore: sync main to core develop (#1) * refactor: extract shared debounce util * fix: project search in admin highlights * fix: add save feedback, cover images and 84px cells to highlights * fix: show selection and empty state in basic dropdown --------- Co-authored-by: Pep Villalonga <pep.villalonga@platoniq.net> --- .../admin/charges/steps/StepUser.svelte | 9 +- src/components/admin/home/Highlights.svelte | 92 ++++++++++++------- .../library/dropdown/DropdownItem.svelte | 15 ++- .../library/dropdown/DropdownMenu.svelte | 49 ++++++---- .../library/filters/FilterComposerRow.svelte | 22 ++--- .../library/inputs/TerritoryInput.svelte | 30 +++--- src/i18n/locales/es.json | 1 + .../[...locale]/admin/home/highlights.astro | 17 +++- src/utils/debounce.ts | 16 ++++ 9 files changed, 166 insertions(+), 85 deletions(-) create mode 100644 src/utils/debounce.ts diff --git a/src/components/admin/charges/steps/StepUser.svelte b/src/components/admin/charges/steps/StepUser.svelte index 6ff215eb..96dd4bc6 100644 --- a/src/components/admin/charges/steps/StepUser.svelte +++ b/src/components/admin/charges/steps/StepUser.svelte @@ -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(); } } diff --git a/src/components/admin/home/Highlights.svelte b/src/components/admin/home/Highlights.svelte index 9472098c..1a76accd 100644 --- a/src/components/admin/home/Highlights.svelte +++ b/src/components/admin/home/Highlights.svelte @@ -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; @@ -132,7 +148,12 @@ const projectIds = slotAssignments .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 +163,9 @@ <Title level={2} variant="headline">{$t("pages.admin.home.highlights.title")}

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

- +
@@ -186,7 +207,7 @@
{#each row as span}
@@ -216,7 +237,14 @@ number: String(index + 1).padStart(2, "0"), })}

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

{slot.projectTitle}

@@ -250,12 +278,14 @@
+{errorMessage} + {#snippet header()} diff --git a/src/components/library/dropdown/DropdownItem.svelte b/src/components/library/dropdown/DropdownItem.svelte index 8b3d4d0d..216bc848 100644 --- a/src/components/library/dropdown/DropdownItem.svelte +++ b/src/components/library/dropdown/DropdownItem.svelte @@ -1,5 +1,7 @@ - -

{$t("header.search")}

-``` - -**In Astro pages** — `t` is injected as a plain function via `Astro.locals`: - -```astro ---- -const { t } = Astro.locals; ---- - -{t("me.page.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 +97,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//.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//.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 - - -Something went wrong. -``` - -### `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//.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` 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(() => ...)` | -| `` | `{@render children()}` | -| `` | `{@render x()}` | - -Props with types: +All library components accept a `class` prop merged with `tailwind-merge`. Replicate it: ```svelte + +
...
``` -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(); ... }}` | +| `` | `{@render children()}` | +| `` | `{@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 - - - - - - -``` +## 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 ``** (`src/layouts/App.svelte`). It renders `
` and `