From 57ca701c0449046d5d9575790db3bc2d616a93c5 Mon Sep 17 00:00:00 2001
From: OlivierSchulbaum
Date: Fri, 21 Aug 2026 19:58:50 +0200
Subject: [PATCH 01/26] New translations es.json (Catalan)
[ci skip]
---
src/i18n/locales/ca.json | 33 ++++++++++++++++++++++-----------
1 file changed, 22 insertions(+), 11 deletions(-)
diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json
index 6d956172..68796481 100644
--- a/src/i18n/locales/ca.json
+++ b/src/i18n/locales/ca.json
@@ -35,6 +35,16 @@
}
},
"validation": {
+ "_invalid_type": "",
+ "_too_big": "",
+ "too_small": "\"{{ value }}\" és massa curt: s'esperen {{ min }} caràcters o més.",
+ "invalid_format": "\"{{ value }}\" té un format incorrecte: no s'ajusta al patró regex `{{ pattern }}`.",
+ "_not_multiple_of": "",
+ "_unrecognized_keys": "",
+ "_invalid_union": "",
+ "_invalid_key": "",
+ "_invalid_element": "",
+ "_invalid_value": "",
"missingRequiredFields": "Un o més camps obligatoris estan buits",
"project": {
"terms": {
@@ -629,6 +639,13 @@
"project": {
"edit": {
"title": "Editant projecte: {{ project }}",
+ "validation": {
+ "titleBadFormat": "El títol ha de contenir almenys una lletra."
+ },
+ "header": {
+ "titlePlaceholder": "Títol del projecte",
+ "unsentChanges": "Hi ha canvis no enviats"
+ },
"tabs": {
"configuration": "Configuració",
"campaign": "Informació de campanya",
@@ -647,6 +664,11 @@
"secondaryLabel": "Idioma secundari del contingut",
"addButton": "Afegir idioma"
},
+ "release": {
+ "title": "Data de sortida a campanya",
+ "subtitle": "Tingues en compte que les campanyes de micromecenatge tenen una durada mínima de 40 dies",
+ "passed": "Aquest projecte ja ha sortit a campanya i no es pot modificar la seva data de sortida"
+ },
"rounds": {
"title": "Tria les rondes que tindrà la teva campanya",
"description": "La configuració predeterminada és de 40 dies cada ronda, pots sol·licitar modificacions al teu assessor si el teu projecte és seleccionat",
@@ -872,17 +894,6 @@
},
"continue": "Pas següent",
"locationPlaceholder": "Ex.: Carrer Gran Via 28, Madrid"
- },
- "errors": {
- "session": {
- "title": "Sessió expirada"
- },
- "storage": {
- "title": "Error d'emmagatzematge",
- "save_failed": "No s'han pogut desar els canvis",
- "quota_exceeded": "Emmagatzematge local ple",
- "close": "Tancar"
- }
}
},
"create": {
From 7940531a677bf54e839a16c970ed9634c073f043 Mon Sep 17 00:00:00 2001
From: OlivierSchulbaum
Date: Fri, 21 Aug 2026 19:58:52 +0200
Subject: [PATCH 02/26] New translations es.json (English)
[ci skip]
---
src/i18n/locales/en.json | 33 ++++++++++++++++++++++-----------
1 file changed, 22 insertions(+), 11 deletions(-)
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 434d42e0..d47ca720 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -35,6 +35,16 @@
}
},
"validation": {
+ "_invalid_type": "",
+ "_too_big": "",
+ "too_small": "\"{{ value }}\" is too short: {{ min }} or more characters expected.",
+ "invalid_format": "\"{{ value }}\" has an invalid format: it does not match the regex pattern `{{ pattern }}`.",
+ "_not_multiple_of": "",
+ "_unrecognized_keys": "",
+ "_invalid_union": "",
+ "_invalid_key": "",
+ "_invalid_element": "",
+ "_invalid_value": "",
"missingRequiredFields": "One or more required fields are empty",
"project": {
"terms": {
@@ -629,6 +639,13 @@
"project": {
"edit": {
"title": "Editing project: {{ project }}",
+ "validation": {
+ "titleBadFormat": "The title must contain at least one letter."
+ },
+ "header": {
+ "titlePlaceholder": "Project title",
+ "unsentChanges": "There are unsent changes"
+ },
"tabs": {
"configuration": "Configuration",
"campaign": "Campaign information",
@@ -647,6 +664,11 @@
"secondaryLabel": "Secondary content language",
"addButton": "Add language"
},
+ "release": {
+ "title": "Campaign launch date",
+ "subtitle": "Please note that crowdfunding campaigns have a minimum duration of 40 days",
+ "passed": "This project has already launched as a campaign and its launch date cannot be changed"
+ },
"rounds": {
"title": "Choose the rounds your campaign will have",
"description": "The default setting is 40 days each round. You can request modifications through your advisor if your project is selected",
@@ -872,17 +894,6 @@
},
"continue": "Next step",
"locationPlaceholder": "E.g.: Calle Gran Vía 28, Madrid"
- },
- "errors": {
- "session": {
- "title": "Session expired"
- },
- "storage": {
- "title": "Storage error",
- "save_failed": "Changes could not be saved",
- "quota_exceeded": "Local storage full",
- "close": "Close"
- }
}
},
"create": {
From 93fc75b59be9028a081ddf794bf4e33f2ab2dabf Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 10:22:06 +0200
Subject: [PATCH 03/26] fix: /create/project without login callback and cached
categories
---
src/pages/[...locale]/create/project.astro | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/pages/[...locale]/create/project.astro b/src/pages/[...locale]/create/project.astro
index b3454165..f4098f8e 100644
--- a/src/pages/[...locale]/create/project.astro
+++ b/src/pages/[...locale]/create/project.astro
@@ -5,21 +5,25 @@ import CreateProjectForm from "../../../components/create/CreateProjectForm.svel
import { apiCategoriesGetCollection } from "../../../openapi/client";
import { toCollectionItems } from "../../../utils/hydra";
import type { Category } from "../../../openapi/client";
+import { goto } from "../../../utils/navigation";
+import { withoutCache } from "../../../openapi/cacheInterceptor";
const { t, lang, langs, session } = Astro.locals;
if (!session) {
- return Astro.redirect("/login");
+ return goto("/login", { query: { callback: "/create/project" } });
}
-const { data: categoriesRaw } = await apiCategoriesGetCollection({
- headers: { "Accept-Language": langs },
-});
+const { data: categoriesRaw } = await withoutCache(() =>
+ apiCategoriesGetCollection({
+ headers: { "Accept-Language": langs },
+ }),
+);
const categories = toCollectionItems(categoriesRaw);
---
-
+
From 0251c94d8f1ceec00dca620544a2c5e4fc9b6866 Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 11:07:22 +0200
Subject: [PATCH 04/26] fix: make TextInput give value for onInput argument
---
src/components/admin/charges/steps/StepCharge.svelte | 3 +--
src/components/library/inputs/TextInput.svelte | 4 ++--
src/components/project/edit/VideoUrlInput.svelte | 2 +-
3 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/components/admin/charges/steps/StepCharge.svelte b/src/components/admin/charges/steps/StepCharge.svelte
index 9dbf6015..8372511a 100644
--- a/src/components/admin/charges/steps/StepCharge.svelte
+++ b/src/components/admin/charges/steps/StepCharge.svelte
@@ -299,8 +299,7 @@
labelText={$t("pages.admin.charges.headers.amount")}
helperText={$t("pages.admin.charges.create.fields.amountHelper")}
required={true}
- onInput={(e) =>
- onAmountInput(e.target instanceof HTMLInputElement ? e.target.value : "")}
+ onInput={(value) => onAmountInput(value.toString())}
onBlur={onAmountBlur}
/>
void;
+ onInput?: (value: string | number) => void;
onBlur?: (event?: FocusEvent) => void;
onFocus?: (event?: FocusEvent) => void;
} = $props();
@@ -55,7 +55,7 @@
id={finalId}
onblur={onBlur}
onfocus={onFocus}
- oninput={onInput}
+ oninput={() => onInput?.(value)}
{name}
{type}
{required}
diff --git a/src/components/project/edit/VideoUrlInput.svelte b/src/components/project/edit/VideoUrlInput.svelte
index f45df98f..21092125 100644
--- a/src/components/project/edit/VideoUrlInput.svelte
+++ b/src/components/project/edit/VideoUrlInput.svelte
@@ -85,7 +85,7 @@
placeholder={$t("pages.project.edit.campaignInfo.media.videoPlaceholder")}
bind:value={videoUrl}
error={validationError}
- onInput={(e) => handleUrlChange((e.target as HTMLInputElement).value)}
+ onInput={(value) => handleUrlChange(value.toString())}
/>
{/if}
From d25e78a9ad4b28f1c418b19a7e52c44c55b7558e Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 13:29:00 +0200
Subject: [PATCH 05/26] fix: update CategorySelect::onChange to camelCase
---
src/components/library/inputs/CategorySelect.svelte | 12 ++++++------
src/components/project/edit/ConfigurationStep.svelte | 2 +-
src/components/search/CategoryFilter.svelte | 2 +-
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/components/library/inputs/CategorySelect.svelte b/src/components/library/inputs/CategorySelect.svelte
index 1a2a2eef..6cbef598 100644
--- a/src/components/library/inputs/CategorySelect.svelte
+++ b/src/components/library/inputs/CategorySelect.svelte
@@ -8,31 +8,31 @@
selected = $bindable([]),
selectedIds = $bindable([]),
max,
- onchange,
+ onChange,
error = undefined,
}: {
options: Category[];
selected?: Category[];
selectedIds?: (number | string)[];
max?: number;
- onchange?: (selected: Category[], option: Category) => void;
+ onChange?: (selected: Category[], option: Category) => void;
error?: string;
} = $props();
function isSelected(option: Category): boolean {
- return selectedIds.includes(option.id);
+ return selectedIds.includes(option.id!);
}
function handleClick(option: Category): void {
if (isSelected(option)) {
selectedIds = selectedIds.filter((id) => id !== option.id);
} else {
- selectedIds = selectedIds.concat(option.id);
+ selectedIds = selectedIds.concat(option.id!);
}
- selected = options.filter((option) => selectedIds.includes(option.id));
+ selected = options.filter((option) => selectedIds.includes(option.id!));
- onchange?.(selected, option);
+ onChange?.(selected, option);
}
function calcTagType(option: Category) {
diff --git a/src/components/project/edit/ConfigurationStep.svelte b/src/components/project/edit/ConfigurationStep.svelte
index 50d2b18b..8c130433 100644
--- a/src/components/project/edit/ConfigurationStep.svelte
+++ b/src/components/project/edit/ConfigurationStep.svelte
@@ -130,7 +130,7 @@
max={2}
{options}
bind:selectedIds={categories}
- onchange={handleCategoryChange}
+ onChange={handleCategoryChange}
/>
{/if}
diff --git a/src/components/search/CategoryFilter.svelte b/src/components/search/CategoryFilter.svelte
index 0c2043fe..4a75761e 100644
--- a/src/components/search/CategoryFilter.svelte
+++ b/src/components/search/CategoryFilter.svelte
@@ -63,7 +63,7 @@ Implements active/inactive pill states matching Figma design
bind:selected
selectedIds={selected.map((s) => s.id!)}
options={categories}
- onchange={(selected) => onCategoryChange?.(selected.map((o) => `${o.id}`))}
+ onChange={(selected) => onCategoryChange?.(selected.map((o) => `${o.id}`))}
/>
{/await}
From ac8d174f9995ca768905652d54e68bfd03b28405 Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 14:40:14 +0200
Subject: [PATCH 06/26] fix: bad look of dropdown menu buttons on too long
values
---
.../library/dropdown/DropdownMenu.svelte | 22 ++++++++++---------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/src/components/library/dropdown/DropdownMenu.svelte b/src/components/library/dropdown/DropdownMenu.svelte
index c339d45a..39b711ed 100644
--- a/src/components/library/dropdown/DropdownMenu.svelte
+++ b/src/components/library/dropdown/DropdownMenu.svelte
@@ -131,16 +131,18 @@
if (!isOpen) isOpen = true;
}}
/>
- {#if clearable && searchValue}
-
-
-
- {/if}
-
+
+ {#if clearable && searchValue}
+
+
+
+ {/if}
+
+
{:else}
Date: Mon, 24 Aug 2026 14:45:04 +0200
Subject: [PATCH 07/26] fix: AddressAutocomplete to TerritoryInput
---
...ocomplete.svelte => TerritoryInput.svelte} | 21 ++++++++++++-------
1 file changed, 13 insertions(+), 8 deletions(-)
rename src/components/library/inputs/{AddressAutocomplete.svelte => TerritoryInput.svelte} (82%)
diff --git a/src/components/library/inputs/AddressAutocomplete.svelte b/src/components/library/inputs/TerritoryInput.svelte
similarity index 82%
rename from src/components/library/inputs/AddressAutocomplete.svelte
rename to src/components/library/inputs/TerritoryInput.svelte
index cec3a0c3..d9722f89 100644
--- a/src/components/library/inputs/AddressAutocomplete.svelte
+++ b/src/components/library/inputs/TerritoryInput.svelte
@@ -1,5 +1,5 @@
- {displayName(territory)}
+ {getTerritoryDisplayName(territory, $locale)}
diff --git a/src/utils/territory.ts b/src/utils/territory.ts
new file mode 100644
index 00000000..687c57bc
--- /dev/null
+++ b/src/utils/territory.ts
@@ -0,0 +1,21 @@
+import type { Territory } from "../openapi/client";
+import iso3166 from "iso-3166-2";
+
+export function getTerritoryDisplayName(territory: Territory, lang: Intl.LocalesArgument): string {
+ const countryNames = new Intl.DisplayNames(lang, { type: "region" });
+ const country = countryNames.of(territory.country!);
+
+ const tag = territory.subLvl2 ?? territory.subLvl1;
+ if (!tag) {
+ return country!;
+ }
+
+ const iso = iso3166.subdivision(tag!);
+ if (!iso || !iso.name) {
+ return country!;
+ }
+
+ const subdivision = iso.name.split(",")[0];
+
+ return `${subdivision}, ${country}`;
+}
\ No newline at end of file
From 1de0665c1b93ec198351abbd33441acc67dc0c0b Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 14:50:20 +0200
Subject: [PATCH 09/26] feat: improve nominatim service
---
src/services/nominatim.ts | 33 ++++++++++++++++++---------------
1 file changed, 18 insertions(+), 15 deletions(-)
diff --git a/src/services/nominatim.ts b/src/services/nominatim.ts
index c480b148..7ebc7782 100644
--- a/src/services/nominatim.ts
+++ b/src/services/nominatim.ts
@@ -1,3 +1,5 @@
+import type { Territory } from "../openapi/client";
+
export const NOMINATIM_ADDRESS = "https://nominatim.openstreetmap.org";
/**
@@ -73,14 +75,7 @@ export interface NominatimResult {
extratags?: Record;
}
-export interface ExtractedTerritory {
- country: string | null;
- subLvl1: string | null;
- subLvl2: string | null;
- address: string | null;
-}
-
-export function extractTerritory(result: NominatimResult): ExtractedTerritory {
+export function extractTerritory(result: NominatimResult): Territory {
const address = result.address ?? {};
const country = address.country_code?.toUpperCase() ?? null;
@@ -98,6 +93,14 @@ export function extractTerritory(result: NominatimResult): ExtractedTerritory {
};
}
+/**
+ * Perform a free-form search query against Nominatim. Requests are locally cached.
+ * @param value
+ * @param limit
+ * @param extratags
+ * @see https://nominatim.org/release-docs/develop/api/Search/
+ * @returns
+ */
export async function searchPlace(
value: string,
limit: number = 6,
@@ -105,13 +108,13 @@ export async function searchPlace(
): Promise {
const url = new URL(
"/search?" +
- new URLSearchParams({
- q: value,
- limit: limit.toString(),
- format: "json",
- addressdetails: "1",
- extratags: extratags ? "1" : "0",
- }),
+ new URLSearchParams({
+ q: value,
+ limit: limit.toString(),
+ format: "json",
+ addressdetails: "1",
+ extratags: extratags ? "1" : "0",
+ }),
NOMINATIM_ADDRESS,
);
From 8a5443503b00d263d052b2949072c7952ed3e616 Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 14:58:09 +0200
Subject: [PATCH 10/26] fix: remove input name from DropdownMenu
---
src/components/library/dropdown/DropdownMenu.svelte | 3 ---
src/components/library/inputs/TerritoryInput.svelte | 3 ---
2 files changed, 6 deletions(-)
diff --git a/src/components/library/dropdown/DropdownMenu.svelte b/src/components/library/dropdown/DropdownMenu.svelte
index 39b711ed..24fe14cb 100644
--- a/src/components/library/dropdown/DropdownMenu.svelte
+++ b/src/components/library/dropdown/DropdownMenu.svelte
@@ -24,7 +24,6 @@
clearable?: boolean;
onClear?: () => void;
singleSelect?: boolean;
- inputName?: string;
onInputBlur?: (e?: FocusEvent) => void;
/** Build the list with selected options at the top. */
label?: string;
@@ -46,7 +45,6 @@
clearable = false,
onClear = undefined,
singleSelect = false,
- inputName = undefined,
onInputBlur = undefined,
label = undefined,
selectedFirst = false,
@@ -122,7 +120,6 @@
onSearch?.(e.currentTarget.value)}
diff --git a/src/components/library/inputs/TerritoryInput.svelte b/src/components/library/inputs/TerritoryInput.svelte
index d9722f89..c756a7f9 100644
--- a/src/components/library/inputs/TerritoryInput.svelte
+++ b/src/components/library/inputs/TerritoryInput.svelte
@@ -16,7 +16,6 @@
value?: string;
placeholder?: string;
helperText?: string;
- name?: string;
error?: string;
onInput?: (territory: Territory) => void;
onBlur?: () => void;
@@ -27,7 +26,6 @@
value = $bindable(""),
placeholder,
helperText,
- name = "address",
error = undefined,
onInput = undefined,
onBlur = undefined,
@@ -89,7 +87,6 @@
singleSelect
clearable
bind:searchValue={value}
- inputName={name}
searchPlaceholder={placeholder}
{options}
bind:selected
From 87d4027a8d6b64a4fe83346a5b51e359198fafcd Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 15:24:18 +0200
Subject: [PATCH 11/26] feat: recover /create/project
---
.../create/CreateProjectForm.svelte | 371 ++----------------
src/i18n/locales/es.json | 27 +-
src/pages/[...locale]/create/project.astro | 2 +-
3 files changed, 39 insertions(+), 361 deletions(-)
diff --git a/src/components/create/CreateProjectForm.svelte b/src/components/create/CreateProjectForm.svelte
index c67f15a6..33cb3b9f 100644
--- a/src/components/create/CreateProjectForm.svelte
+++ b/src/components/create/CreateProjectForm.svelte
@@ -7,244 +7,29 @@
apiProjectsIdPatch,
type Category,
type ProjectProjectCreationDto,
+ type ApiProjectsPostData,
} from "../../openapi/client";
import { client } from "../../openapi/client/client.gen";
import { apiCategoriesIdOrSlugGetUrl } from "../../openapi/client/operation-paths.gen";
- import {
- validateCreateForm,
- validateField,
- type ValidationErrors,
- } from "../../stores/drafts/draftValidation";
- import {
- createDraft,
- currentDraft,
- markFieldAsTouched,
- touchedFields,
- updateProject,
- isCreateFormValid,
- project,
- validationErrors,
- type CreateProjectForm,
- } from "../../stores/drafts/projectDraft";
import { maxEndDate } from "../../utils/campaign";
import { getDefaultCurrency } from "../../utils/consts";
import { formatCurrency } from "../../utils/currencies";
import Button from "../library/buttons/Button.svelte";
import BaseCard from "../library/cards/BaseCard.svelte";
- import AddressAutocomplete from "../library/inputs/AddressAutocomplete.svelte";
import CategorySelect from "../library/inputs/CategorySelect.svelte";
import DateInput from "../library/inputs/DateInput.svelte";
import TextInput from "../library/inputs/TextInput.svelte";
import Title from "../library/typography/Title.svelte";
-
- let releaseDate = $state($project.release ? new Date($project.release) : new Date());
+ import TextArea from "../library/inputs/TextArea.svelte";
+ import TerritoryInput from "../library/inputs/TerritoryInput.svelte";
+ import { twJoin } from "tailwind-merge";
let { categories }: { categories: Category[] } = $props();
- // Track if form has been submitted once (for showing all errors)
- let submitted = $state(false);
-
- // Loading state for form submission
- let isSubmitting = $state(false);
-
- // API error message
- let apiError: string | null = $state(null);
-
- // Success state
- let submitSuccess = $state(false);
-
- // Debounce timer for real-time validation
- let debounceTimer: ReturnType | null = null;
-
- function handleFieldBlur(fieldName: keyof CreateProjectForm) {
- if (!$currentDraft) return;
-
- markFieldAsTouched(fieldName);
- const error = validateField(fieldName, $currentDraft.createProject[fieldName]);
-
- validationErrors.update((errors: ValidationErrors) => {
- if (error) {
- return {
- ...errors,
- [fieldName]: error,
- };
- }
-
- const { [fieldName]: _, ...rest } = errors;
-
- return rest;
- });
- }
-
- /**
- * Handles field value changes with type-safe updates.
- * Uses generic typing to ensure type safety without bypassing TypeScript checks.
- */
- function handleFieldChange(
- fieldName: K,
- value: CreateProjectForm[K],
- ) {
- // Update the draft value with proper typing
- updateProject({ [fieldName]: value });
-
- // Only validate on change if field has been touched
- if ($touchedFields.has(fieldName) || submitted) {
- // Debounce validation
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- const error = validateField(fieldName, value);
-
- validationErrors.update((errors: ValidationErrors) => {
- if (error) {
- return {
- ...errors,
- [fieldName]: error,
- };
- }
-
- const { [fieldName]: _, ...rest } = errors;
-
- return rest;
- });
- }, 300);
- }
- }
-
- // Cleanup debounce timer on component unmount to prevent memory leaks
- onDestroy(() => {
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- });
-
- function handleCategoryChange(selected: Category[]) {
- const categoryIris = selected.map((s) => {
- return client.buildUrl({ url: apiCategoriesIdOrSlugGetUrl, path: { idOrSlug: s.id } });
- });
-
- handleFieldChange("categories", categoryIris);
- }
-
- async function handleSubmit() {
- submitted = true;
- apiError = null;
-
- const errors = validateCreateForm($project);
-
- // Validate entire form
- const isValid = Object.keys(errors).length === 0;
-
- if (!isValid) {
- // Scroll to error summary
- const errorSummary = document.querySelector('[role="alert"]');
- if (errorSummary) {
- errorSummary.scrollIntoView({ behavior: "smooth", block: "start" });
- } else {
- // If no error summary, focus first invalid field
- const firstError = Object.keys($validationErrors)[0];
- if (firstError) {
- const element = document.querySelector(`[name="${firstError}"]`);
- if (element instanceof HTMLElement) {
- element.focus();
- }
- }
- }
- return;
- }
-
- if (!$currentDraft) {
- apiError = "Draft not initialized";
- return;
- }
-
- // Submit to API
- isSubmitting = true;
-
- try {
- const { data, error } = await apiProjectsPost({
- baseUrl: "/api/relay",
- body: {
- title: $currentDraft.createProject.title,
- subtitle: $currentDraft.createProject.subtitle,
- categories: $currentDraft.createProject.categories,
- calendar: { release: $currentDraft.createProject.release ?? null },
- },
- });
-
- if (error) {
- if ("violations" in error && error.violations) {
- error.violations.forEach((violation) => {
- const field = violation.propertyPath as keyof ProjectProjectCreationDto;
- if (field) {
- $validationErrors[field] = violation.message || "Invalid value";
- }
- });
- return;
- } else {
- apiError = "An unexpected error occurred. Please try again.";
- return;
- }
- } else if (data) {
- const address = $currentDraft.createProject.address;
- const territory = $currentDraft.createProject.territory;
-
- if (address && territory) {
- await apiProjectsIdPatch({
- baseUrl: "/api/relay",
- path: { id: String(data.id) },
- body: {
- territory: {
- ...territory,
- address,
- },
- },
- });
- }
-
- submitSuccess = true;
- updateProject({
- title: $currentDraft.createProject.title,
- subtitle: $currentDraft.createProject.subtitle,
- categories: $currentDraft.createProject.categories,
- release: $currentDraft.createProject.release,
- address: $currentDraft.createProject.address,
- territory: $currentDraft.createProject.territory,
- });
- currentDraft.update((d) => (d ? { ...d, status: "project-created" } : d));
-
- // Redirect to project page
- window.location.href = `/project/${data.id}/edit`;
- }
- } catch {
- // Handle unexpected errors
- apiError = "An unexpected error occurred. Please try again.";
- } finally {
- isSubmitting = false;
- }
- }
-
- // Helper to check if field should show error
- function shouldShowError(fieldName: string): boolean {
- return (submitted || $touchedFields.has(fieldName)) && !!$validationErrors[fieldName];
- }
-
- // Calculate minimum date (14 days from now) for date input
- function getMinDate(): Date {
- const minDate = new Date();
- minDate.setDate(minDate.getDate() + 14);
- return minDate;
- }
-
- $effect(() => {
- if (!$currentDraft) {
- createDraft({
- title: "",
- subtitle: "",
- categories: [],
- release: undefined,
- });
- }
+ let form = $state({
+ title: "",
+ subtitle: "",
+ categories: [],
});
@@ -258,77 +43,25 @@
{$t("pages.project.create.subtitle")}
- {#if submitted && Object.keys($validationErrors).length > 0}
-
-
- {$t("system.validation.errors.summary.title")}
-
-
-
- {/if}
+
{$t("pages.project.create.description.title")}
- {$t("pages.project.create.description.subtitle")}
+ {$t("pages.project.create.description.description")}
handleFieldBlur("title")}
- onInput={(e) => handleFieldChange("title", (e.target as HTMLInputElement).value)}
+ bind:value={form.title}
+ helperText={$t("pages.project.create.description.titlePrompt")}
+ placeholder={$t("pages.project.create.description.titlePlaceholder")}
/>
-
- {#if shouldShowError("subtitle")}
-
- {$t($validationErrors.subtitle)}
-
- {/if}
+
@@ -338,12 +71,7 @@
{$t("pages.project.create.categories.subtitle")}
-
+
@@ -352,17 +80,7 @@
{$t("pages.project.create.address.subtitle")}
- {
- handleFieldChange("address", address);
- handleFieldChange("territory", territory);
- }}
- onBlur={() => handleFieldBlur("address")}
- />
+
@@ -371,35 +89,11 @@
{$t("pages.project.create.release.subtitle")}
- handleFieldBlur("release")}
- onInput={(date: any) => handleFieldChange("release", date)}
- />
+
- {#if apiError}
-
- {/if}
- {#if submitSuccess}
-
-
- {$t("pages.project.create.submitSuccess")}
-
-
- {/if}
-
-
- {isSubmitting
- ? $t("pages.project.create.submitting")
- : $t("pages.project.create.submit")}
-
-
+
+ {$t("pages.project.create.submit")}
+
- {$currentDraft?.createProject.title ||
- $t("pages.project.create.description.titlePlaceholder")}
+ {form.title || $t("pages.project.create.description.titlePlaceholder")}
- {$currentDraft?.createProject.subtitle ||
- $t("pages.project.create.description.subtitlePlaceholder")}
+ {form.subtitle || $t("pages.project.create.description.subtitlePlaceholder")}
-
-
{$t("pages.project.create.budgetPreview")}
-
- {formatCurrency(
- $currentDraft?.wizardForm.budget?.minimum?.money?.amount || 0,
- getDefaultCurrency(),
- )}
-
-
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 810fba65..8acc8bd7 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -897,40 +897,35 @@
}
},
"create": {
- "title": "Propón una campaña",
- "subtitle": "Describe brevemete el proyecto y la campaña de crowdfunding.",
- "budgetPreview": "Dinero necesario",
+ "title": "Crea un proyecto",
+ "subtitle": "Describe brevemete tu proyecto de crowdfunding.",
"description": {
"title": "Describe brevemente tu propuesta",
- "titlePrompt": "Indica el nombre de tu campaña",
- "titlePlaceholder": "Nombre de campaña",
- "subtitle": "Indica el nombre de tu campaña y describe brevemente el proyecto que realizarás si consigues la financiación.",
+ "description": "Indica el nombre de tu proyecto y describe brevemente lo que realizarás si consigues la financiación.",
+ "titlePrompt": "Indica el nombre de tu proyecto",
+ "titlePlaceholder": "Mi proyecto",
"subtitlePrompt": "Indica brevemente la descripción de tu proyecto.",
- "subtitlePlaceholder": "Sin descripción"
+ "subtitlePlaceholder": "Un proyecto para hacer el mundo un poquito mejor"
},
"categories": {
- "title": "¿Qué categoría se ajusta a tu campaña?",
+ "title": "¿Qué categoría se ajusta a tu proyecto?",
"subtitle": "(Puedes elegir un máximo de 2)"
},
"address": {
"title": "¿Cuál es la dirección del proyecto?",
"subtitle": "Busca y selecciona la dirección completa del proyecto. Esto nos ayuda a ubicar tu iniciativa.",
- "placeholder": "Ej: Calle Gran Vía 28, Madrid"
- },
- "budget": {
- "title": "¿Cuánto dinero necesitas?",
- "subtitle": "Selecciona la cantidad que necesites para llevar a cabo tu proyecto."
+ "placeholder": "Forn de l'Olivera 22, Palma"
},
"release": {
- "title": "Fecha salida de campaña",
+ "title": "Fecha de salida a campaña",
"subtitle": "(Ten en cuenta que las campañas de crowdfunding tienen una duración mínima de 40 días)"
},
"terms": {
"label": "Acepto las condiciones del impulsor y las bases de la convocatoria"
},
- "submit": "Proponer campaña",
+ "submit": "Crear proyecto",
"submitting": "Enviando…",
- "submitSuccess": "¡Campaña propuesta correctamente!"
+ "submitSuccess": "Proyecto creado"
},
"view": {
"owner": "Campaña de crowdfunding impulsada por {{ owner }}",
diff --git a/src/pages/[...locale]/create/project.astro b/src/pages/[...locale]/create/project.astro
index f4098f8e..7a378a47 100644
--- a/src/pages/[...locale]/create/project.astro
+++ b/src/pages/[...locale]/create/project.astro
@@ -24,6 +24,6 @@ const categories = toCollectionItems(categoriesRaw);
-
+
From 800bd4855ddb019253e20adda24690a23effab9e Mon Sep 17 00:00:00 2001
From: Daniel Subiabre
Date: Mon, 24 Aug 2026 15:44:22 +0200
Subject: [PATCH 12/26] feat: add validation in CreateProjectForm
---
.../create/CreateProjectForm.svelte | 30 +++++++++++++++++--
src/i18n/locales/es.json | 5 +++-
2 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/src/components/create/CreateProjectForm.svelte b/src/components/create/CreateProjectForm.svelte
index 33cb3b9f..09bb054b 100644
--- a/src/components/create/CreateProjectForm.svelte
+++ b/src/components/create/CreateProjectForm.svelte
@@ -23,14 +23,38 @@
import TextArea from "../library/inputs/TextArea.svelte";
import TerritoryInput from "../library/inputs/TerritoryInput.svelte";
import { twJoin } from "tailwind-merge";
+ import type z from "zod";
+ import { zProjectProjectCreationDto } from "../../openapi/client/zod.gen";
let { categories }: { categories: Category[] } = $props();
- let form = $state({
+ let form: ApiProjectsPostData["body"] = $state({
title: "",
subtitle: "",
categories: [],
});
+
+ let validation: Partial> = $state({});
+
+ function validate(field: keyof typeof form) {
+ const result = zProjectProjectCreationDto.shape[field].safeParse(form[field]);
+
+ validation[field] = result.error?.issues;
+ }
+
+ function getValidationMessage(field: keyof typeof form) {
+ const issue = validation[field]?.[0];
+
+ if (!issue) {
+ return "";
+ }
+
+ if (issue.code === "invalid_format" && field === "title") {
+ return $t("pages.project.create.validation.titleBadFormat");
+ }
+
+ return $t(`system.validation.${issue.code}`);
+ }
@@ -53,8 +77,10 @@
validate("title")}
helperText={$t("pages.project.create.description.titlePrompt")}
placeholder={$t("pages.project.create.description.titlePlaceholder")}
+ error={getValidationMessage("title")}
/>