- {#if update.date}
- {formatDate(new Date(update.date), $locale)}
- {/if}
-
+
diff --git a/src/components/project/edit/CampaignInfoStep.svelte b/src/components/project/edit/CampaignInfoStep.svelte
index 1c60b632..d9e3364d 100644
--- a/src/components/project/edit/CampaignInfoStep.svelte
+++ b/src/components/project/edit/CampaignInfoStep.svelte
@@ -18,14 +18,13 @@
import RichTextEditor from "./RichTextEditor.svelte";
import VideoUrlInput from "./VideoUrlInput.svelte";
import { t } from "../../../i18n/store";
+ import { validateCampaignInfo } from "../../../stores/drafts/draftValidation";
import {
- wizardState,
- updateCampaignInfo,
+ currentDraft,
navigateToStep,
- validateCampaignInfo,
+ updateCampaignInfo,
type MediaImage,
- type VideoEmbed,
- } from "../../../stores/wizard-state";
+ } from "../../../stores/drafts/projectDraft";
import Button from "../../library/Button.svelte";
import Grid from "../../library/Grid.svelte";
@@ -36,14 +35,24 @@
let { onContinue }: CampaignInfoStepProps = $props();
// Reactive values from store
- const campaignInfo = $derived($wizardState.campaignInfo);
+ const campaignInfo = $derived(
+ $currentDraft?.wizardForm.campaignInfo ?? {
+ images: [],
+ video: undefined,
+ objectives: "",
+ legacy: "",
+ targetAudience: "",
+ team: "",
+ },
+ );
/**
* Handle Continue button
* Simple navigation to next step - validation happens on save/submit
*/
function handleContinue() {
- const errors = validateCampaignInfo();
+ if (!$currentDraft) return;
+ const errors = validateCampaignInfo($currentDraft.wizardForm);
if (Object.keys(errors).length === 0) {
navigateToStep(3);
@@ -63,6 +72,7 @@
* Handle image upload
*/
function handleImageUpload(image: MediaImage) {
+ if (!campaignInfo) return;
updateCampaignInfo({
images: [...campaignInfo.images, image],
});
@@ -80,29 +90,37 @@
/**
* Handle video change
*/
- function handleVideoChange(video: VideoEmbed | null) {
- updateCampaignInfo({ video });
+ function handleVideoChange(video: string | null) {
+ updateCampaignInfo({
+ video: video ?? "",
+ });
}
/**
* Handle objectives change
*/
function handleObjectivesChange(html: string) {
- updateCampaignInfo({ objectives: html });
+ updateCampaignInfo({
+ objectives: html,
+ });
}
/**
* Handle legacy change
*/
function handleLegacyChange(html: string) {
- updateCampaignInfo({ legacy: html });
+ updateCampaignInfo({
+ legacy: html,
+ });
}
/**
* Handle target audience change
*/
function handleTargetAudienceChange(html: string) {
- updateCampaignInfo({ targetAudience: html });
+ updateCampaignInfo({
+ targetAudience: html,
+ });
}
/**
diff --git a/src/components/project/edit/CollaborationsStep.svelte b/src/components/project/edit/CollaborationsStep.svelte
index 97435d78..aecfc2b8 100644
--- a/src/components/project/edit/CollaborationsStep.svelte
+++ b/src/components/project/edit/CollaborationsStep.svelte
@@ -1,16 +1,16 @@
@@ -49,10 +49,10 @@
{:else}
{#each collabs as collab, index}
-
+
{/each}
-
+
{/if}
diff --git a/src/components/project/edit/CollabsCard.svelte b/src/components/project/edit/CollabsCard.svelte
index f6591ecf..2f4edcf5 100644
--- a/src/components/project/edit/CollabsCard.svelte
+++ b/src/components/project/edit/CollabsCard.svelte
@@ -7,18 +7,21 @@
deleteCollaboration,
updateCollaboration,
validationErrors,
- type WizardCollaboration,
- } from "../../../stores/wizard-state";
+ } from "../../../stores/drafts/projectDraft";
import { renderMarkdown } from "../../../utils/renderMarkdown";
import Button from "../../library/Button.svelte";
+ import type { Project, ProjectCollaboration } from "../../../openapi/client";
+
let {
+ project,
collab,
index,
loading = $bindable(false),
isCreateCard = false,
}: {
- collab: WizardCollaboration | null;
+ project: Project;
+ collab: ProjectCollaboration | null;
index?: number;
loading: boolean;
isCreateCard?: boolean;
@@ -27,7 +30,7 @@
let openModal = $state(false);
let showModalErrorToast = $state(false);
- function handleSaveCollab(data: WizardCollaboration | null) {
+ function handleSaveCollab(data: ProjectCollaboration | null) {
if (!data) return;
let errors;
@@ -62,6 +65,7 @@
{#if isCreateCard}
void;
+ project: Project;
+ collab: ProjectCollaboration | null;
+ onSave: (data: ProjectCollaboration | null) => void;
onDelete?: () => void;
} = $props();
@@ -31,7 +35,9 @@
"border-secondary text-content items-center rounded-lg border bg-white p-4 text-base font-normal placeholder:opacity-48 focus:ring-0";
function handleSaveOrCreate() {
- onSave({ title, description });
+ const projectIri = apiProjectsGetCollectionUrl + "/" + (project.slug ?? project.id);
+
+ onSave({ project: projectIri, title, description, isFulfilled: false });
}
function handleDeleteClick() {
diff --git a/src/components/project/edit/ConfigurationStep.svelte b/src/components/project/edit/ConfigurationStep.svelte
index faa89629..6502f56e 100644
--- a/src/components/project/edit/ConfigurationStep.svelte
+++ b/src/components/project/edit/ConfigurationStep.svelte
@@ -11,7 +11,11 @@
@@ -51,26 +45,22 @@
{description}
-
+
{#if variant === "reward"}
-
+
{:else if variant === "collab"}
-
+
{:else if variant === "budget"}
-
+
{/if}
diff --git a/src/components/project/edit/GeoSelector.svelte b/src/components/project/edit/GeoSelector.svelte
index 9a518dd4..f3a2371e 100644
--- a/src/components/project/edit/GeoSelector.svelte
+++ b/src/components/project/edit/GeoSelector.svelte
@@ -15,10 +15,10 @@
@@ -56,12 +105,13 @@
{$t("pages.project.edit.aboutYou.subtitle")}
-
+
@@ -89,11 +139,14 @@
{$t("pages.project.edit.aboutYou.name")}
{$t("pages.project.edit.aboutYou.nameHelper")}
-
+
touch("name")}>
+
+
@@ -102,14 +155,17 @@
{$t("pages.project.edit.aboutYou.taxId")}
{$t("pages.project.edit.aboutYou.taxIdHelper")}
-
+ touch("taxId")}>
+
+
@@ -160,14 +218,23 @@
{#each privateContacts as contact, i}
-
+
touch(`contact-${i}`)}>
setPreferred(i)}
/>
+ {#if contact.type !== "email"}
+
+ {/if}
{/each}
+
-
+
+
+
+
+
+
+ {$t("pages.project.edit.aboutYou.paymentData.title")}
+
+
+ {$t("pages.project.edit.aboutYou.paymentData.subtitle")}
+
+
+
+
touch("iban")}>
+
+
+
+
+ {$t("pages.project.edit.aboutYou.paymentData.bankCertificate")}
+
+
+ {$t("pages.project.edit.aboutYou.paymentData.bankCertificateHelper")}
+
+
+
+
+
diff --git a/src/components/project/edit/RewardsCard.svelte b/src/components/project/edit/RewardsCard.svelte
index 41a5a039..e471f61b 100644
--- a/src/components/project/edit/RewardsCard.svelte
+++ b/src/components/project/edit/RewardsCard.svelte
@@ -7,20 +7,23 @@
deleteReward,
updateReward,
validationErrors,
- type WizardReward,
- } from "../../../stores/wizard-state";
+ } from "../../../stores/drafts/projectDraft";
import UnitIcon from "../../../svgs/UnitIcon.svelte";
import { formatCurrency } from "../../../utils/currencies";
import { renderMarkdown } from "../../../utils/renderMarkdown";
import Button from "../../library/Button.svelte";
+ import type { Project, ProjectReward } from "../../../openapi/client";
+
let {
+ project,
reward,
index,
loading = $bindable(false),
isCreateCard = false,
}: {
- reward: WizardReward | null;
+ project: Project;
+ reward: ProjectReward | null;
index?: number;
loading: boolean;
isCreateCard?: boolean;
@@ -29,7 +32,7 @@
let openModal = $state(false);
let showModalErrorToast = $state(false);
- function handleSaveReward(data: WizardReward | null) {
+ function handleSaveReward(data: ProjectReward | null) {
if (!data) return;
let errors;
@@ -64,6 +67,7 @@
{#if isCreateCard}
void;
+ project: Project;
+ reward: ProjectReward | null;
+ onSave: (data: ProjectReward | null) => void;
onDelete?: () => void;
} = $props();
@@ -40,7 +44,10 @@
"border-secondary text-content items-center rounded-lg border bg-white p-4 text-base font-normal placeholder:opacity-48 focus:ring-0";
function handleSaveOrCreate() {
+ const projectIri = apiProjectsGetCollectionUrl + "/" + (project.slug ?? project.id);
+
onSave({
+ project: projectIri,
title,
description,
money: {
diff --git a/src/components/project/edit/RewardsStep.svelte b/src/components/project/edit/RewardsStep.svelte
index e9bdd9d9..20a41a3e 100644
--- a/src/components/project/edit/RewardsStep.svelte
+++ b/src/components/project/edit/RewardsStep.svelte
@@ -1,12 +1,16 @@
@@ -42,10 +46,10 @@
{:else}
{#each rewards as reward, index}
-
+
{/each}
-
+
{/if}
diff --git a/src/components/project/edit/VideoUrlInput.svelte b/src/components/project/edit/VideoUrlInput.svelte
index d3ab055c..a829d8a8 100644
--- a/src/components/project/edit/VideoUrlInput.svelte
+++ b/src/components/project/edit/VideoUrlInput.svelte
@@ -1,54 +1,3 @@
-
{#if !video}
-
- {:else if embedPreview}
+ {:else}
-
-
-
+
+ {video}
-
diff --git a/src/components/search/SearchButton.svelte b/src/components/search/SearchButton.svelte
index 5c00ebfd..cbfb9c16 100644
--- a/src/components/search/SearchButton.svelte
+++ b/src/components/search/SearchButton.svelte
@@ -26,7 +26,7 @@ Supports: primary (Aplicar filtros), secondary (Buscar), ghost (Cerrar filtros)
// Base button classes - matching Figma design exactly
const baseClasses =
- "inline-flex items-center justify-center font-body font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed gap-2";
+ "inline-flex items-center justify-center font-body font-bold transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed gap-2";
// Size classes - matching Figma specifications exactly
const sizeClasses = {
diff --git a/src/components/search/SearchFilters.svelte b/src/components/search/SearchFilters.svelte
index 4e3409f0..2b950046 100644
--- a/src/components/search/SearchFilters.svelte
+++ b/src/components/search/SearchFilters.svelte
@@ -18,9 +18,9 @@ Integrated with searchStore for state management and URL synchronization
interface Props {
locale?: string;
initialFilters?: {
- query?: string;
- statusFilter?: string;
- categories?: string[];
+ title?: string;
+ status?: string;
+ "categories[]"?: string[];
};
}
@@ -36,9 +36,9 @@ Integrated with searchStore for state management and URL synchronization
// Initialize with server-provided filters if available (without triggering search)
if (initialFilters) {
searchStore.initializeFilters({
- query: initialFilters.query || "",
- statusFilter: initialFilters.statusFilter || "",
- categories: initialFilters.categories || [],
+ title: initialFilters.title || "",
+ status: initialFilters.status || "",
+ "categories[]": initialFilters["categories[]"] || [],
});
}
});
@@ -72,19 +72,19 @@ Integrated with searchStore for state management and URL synchronization
updateFilters({ query })}
+ value={$searchFilters.title}
+ onSearch={(title) => updateFilters({ title })}
onEnter={handleSearch}
onClear={handleSearch}
placeholder={$t("pages.search.input.placeholder")}
@@ -104,18 +104,16 @@ Integrated with searchStore for state management and URL synchronization
- {#if !filtersOpen}
-
-
- {$t("pages.search.filters.show")}
-
- {/if}
+
+
+ {filtersOpen ? $t("pages.search.filters.close") : $t("pages.search.filters.show")}
+
@@ -125,8 +123,8 @@ Integrated with searchStore for state management and URL synchronization
updateFilters({ statusFilter: value })}
+ selectedValue={$searchFilters.status}
+ onSelect={(value) => updateFilters({ status: value })}
data-testid="status-filter"
/>
@@ -134,8 +132,8 @@ Integrated with searchStore for state management and URL synchronization
updateFilters({ categories })}
+ selectedCategories={$searchFilters["categories[]"] || []}
+ onCategoryChange={(categories) => updateFilters({ "categories[]": categories })}
data-testid="category-filter"
/>
@@ -153,17 +151,6 @@ Integrated with searchStore for state management and URL synchronization
>
{$t("pages.search.filters.apply")}
-
-
-
-
- {$t("pages.search.filters.close")}
-
{/if}
diff --git a/src/components/search/SearchInput.svelte b/src/components/search/SearchInput.svelte
index 58069e30..08bcda0c 100644
--- a/src/components/search/SearchInput.svelte
+++ b/src/components/search/SearchInput.svelte
@@ -1,33 +1,29 @@
-
-
-
-
-
-
-
- {#if searchQuery}
-
- {/if}
-
-
-
-
- {$t("pages.search.input.label")}
-
-
-
-
-
+
onEnter?.() || onSearch?.(searchQuery)}
+ onclear={clearSearch}
+ data-testid={dataTestId}
+ class="transition-colors focus-within:border-[#855a96] {className}"
+/>
diff --git a/src/components/search/SearchResultsClient.svelte b/src/components/search/SearchResultsClient.svelte
index 581db254..5d4fba70 100644
--- a/src/components/search/SearchResultsClient.svelte
+++ b/src/components/search/SearchResultsClient.svelte
@@ -32,9 +32,9 @@ Manages real-time filtering of campaigns without page reloads
initialProjects: Project[];
ariaLiveRegion?: "polite" | "assertive" | "off";
initialFilters?: {
- query?: string;
- statusFilter?: string;
- categories?: string[];
+ title?: string;
+ status?: string;
+ "categories[]"?: string[];
};
hasInitialSearch?: boolean;
}
@@ -93,10 +93,14 @@ Manages real-time filtering of campaigns without page reloads
const filters = $searchFilters;
- // Add search parameters to URL
- if (filters.query) params.set("q", filters.query);
- if (filters.statusFilter) params.set("status", filters.statusFilter);
- if (filters.categories.length > 0) params.set("categories", filters.categories.join(","));
+ // Add search parameters to URL using API field names
+ if (filters.title) params.set("title", filters.title);
+ if (filters.status) params.set("status", filters.status);
+ if ((filters["categories[]"]?.length ?? 0) > 0) {
+ (filters["categories[]"] as string[]).forEach((cat) => {
+ params.append("categories[]", cat);
+ });
+ }
// Update URL using History API to avoid navigation
const newUrl = `${url.pathname}${params.toString() ? "?" + params.toString() : ""}`;
@@ -129,9 +133,9 @@ Manages real-time filtering of campaigns without page reloads
if (initialFilters && hasInitialSearch) {
// Initialize store with server-side filters first
searchStore.initializeFilters({
- query: initialFilters.query || "",
- statusFilter: initialFilters.statusFilter || "",
- categories: initialFilters.categories || [],
+ title: initialFilters.title || "",
+ status: initialFilters.status || "",
+ "categories[]": initialFilters["categories[]"] || [],
});
// Trigger API search with the URL parameters
diff --git a/src/firewall/access-control.ts b/src/firewall/access-control.ts
index 77d6ad60..f4bd6f6d 100644
--- a/src/firewall/access-control.ts
+++ b/src/firewall/access-control.ts
@@ -3,4 +3,13 @@ export type ControlItem = {
roles: string[];
};
-export const ACL: ControlItem[] = [{ path: "/admin", roles: ["ROLE_ADMIN"] }];
+export const ACL: ControlItem[] = [
+ {
+ path: "/project/.*/edit",
+ roles: ["ROLE_USER"],
+ },
+ {
+ path: "/admin",
+ roles: ["ROLE_ADMIN"],
+ },
+];
diff --git a/src/firewall/index.ts b/src/firewall/index.ts
index 4bea9c05..23d7b9dd 100644
--- a/src/firewall/index.ts
+++ b/src/firewall/index.ts
@@ -1,6 +1,8 @@
import { ACL, type ControlItem } from "./access-control";
import { isSupportedLocale } from "../i18n/locales";
+const regexCache = new Map();
+
function normalizePath(pathname: string): string {
const path = pathname.replace(/\/+$/, "");
@@ -13,8 +15,42 @@ function normalizePath(pathname: string): string {
return "/" + segments.join("/");
}
-function matchesPath(pathname: string, basePath: string): boolean {
- return pathname === basePath || pathname.startsWith(basePath + "/");
+function isValidRegex(pattern: string): boolean {
+ try {
+ new RegExp(pattern);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function getRegex(path: string): RegExp {
+ if (!regexCache.has(path)) {
+ regexCache.set(path, new RegExp(`^${path}$`));
+ }
+
+ return regexCache.get(path)!;
+}
+
+/**
+ * Checks if a pathname matches a given path.
+ * @param pathname The pathname to check, e.g. "/admin/dashboard"
+ * @param path The path to match against, which can be an exact path (e.g. "/admin") or a regex pattern (e.g. "/admin/.*")
+ * @returns True if the pathname matches the path, false otherwise
+ */
+function matchesPath(pathname: string, path: string): boolean {
+ // Exact match
+ if (pathname === path) {
+ return true;
+ }
+
+ // Dynamic regex route
+ if (isValidRegex(path)) {
+ return getRegex(path).test(pathname);
+ }
+
+ // Nested routes
+ return pathname.startsWith(path + "/");
}
export function getMatchingACL(pathname: string): ControlItem | null {
@@ -29,6 +65,16 @@ export function getMatchingACL(pathname: string): ControlItem | null {
return null;
}
+/**
+ * Checks if a user is authorized to access a control item.
+ * @param control The control item (ACL) to check authorization for
+ * @param roles The roles of the current user
+ * @returns True if the user is authorized, false otherwise
+ */
export function isAuthorized(control: ControlItem, roles: string[]): boolean {
+ if (control.roles.length === 0) {
+ return true;
+ }
+
return control.roles.some((role) => roles.includes(role));
}
diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json
index 0a06ef1f..421688da 100644
--- a/src/i18n/locales/ca.json
+++ b/src/i18n/locales/ca.json
@@ -843,6 +843,8 @@
"content-footer": {
"lastEdited": "Última modificació: {{date}} {{time}}",
"downloadDetails": "Descarrega el detall",
+ "downloadCertificates": "Descarrega certificats",
+ "downloadPdf": "Descarrega PDF",
"annotations": "{{annotations}} Anotacions"
}
},
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 1c6a93be..dea1f438 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -843,6 +843,8 @@
"content-footer": {
"lastEdited": "Last modification: {{date}} {{time}}",
"downloadDetails": "Download details",
+ "downloadCertificates": "Download certificates",
+ "downloadPdf": "Download PDF",
"annotations": "{{annotations}} annotations"
}
},
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 95723ef8..cda73b20 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -1,6 +1,6 @@
{
"common": {
- "_": "Global UI translations (buttons, labels, etc.)",
+ "_": "Traducciones globales de la interfaz (botones, etiquetas, etc.)",
"greeting": "Hola",
"farewell": "Adiós",
"donate": "Donar",
@@ -17,16 +17,22 @@
"remove": "Eliminar",
"textPlaceholder": "Escribe aquí",
"remember": "Recordar",
+ "search": "Buscar",
+ "searchEtc": "Buscar...",
"countdown": {
"remaining": "Quedan",
"expired": "Campaña finalizada"
},
"login": "Iniciar Sesión",
"goHome": "Volver a inicio",
- "viewAll": "Ver todo"
+ "viewAll": "Ver todo",
+ "noData": "No hay datos disponibles",
+ "tooltip": {
+ "copied": "¡Copiado!"
+ }
},
"domain": {
- "_": "Business concepts (project, reward, contribution, etc.)",
+ "_": "Conceptos de negocio (proyecto, recompensa, contribución, etc.)",
"header": {
"categories": "Proyectos"
},
@@ -62,6 +68,41 @@
"completed": "Completadas"
}
},
+ "gatewayCheckout": {
+ "paymentMethod": "Método",
+ "payments": {
+ "tpv": "Tarjeta",
+ "paypal": "Paypal",
+ "stripe": "Stripe",
+ "wallet": "Monedero"
+ }
+ },
+ "charges": {
+ "status": {
+ "to_charge": "Por cobrar",
+ "in_charge": "Cobrado",
+ "to_refund": "Por reembolsar",
+ "refunded": "Reembolsado",
+ "to_wallet": "Por reembolsar a monedero",
+ "walleted": "Reembolsado a monedero"
+ },
+ "refund": {
+ "to_wallet": "Sí",
+ "to_gateway": "No"
+ },
+ "entityLabels": {
+ "projects": "Proyectos",
+ "users": "Usuarios",
+ "tipjars": "Botes de propina",
+ "tipjar-id": "ID del bote de propina"
+ },
+ "lastContributions": "Últimos aportes:",
+ "totalizers": {
+ "totalCharges": "Total aportes:",
+ "totalTips": "Total propinas:",
+ "totalFees": "Comisiones totales:"
+ }
+ },
"review": {
"risks": {
"low": "Riesgo bajo",
@@ -71,7 +112,7 @@
}
},
"pages": {
- "_": "Page-specific content (/home, /search, /project/[id], etc.)",
+ "_": "Contenido específico de páginas (/home, /search, /project/[id], etc.)",
"home": {
"banner": {
"title": "¡Renovamos Goteo!",
@@ -531,6 +572,17 @@
"twitter": "Inserta tu cuenta de X/Twitter",
"linkedin": "Inserta tu cuenta de LinkedIn"
},
+ "paymentData": {
+ "title": "Datos de pago",
+ "subtitle": "Indica los datos bancarios necesarios para gestionar los pagos de la campaña.",
+ "iban": "IBAN",
+ "ibanHelper": "Introduce el IBAN de la cuenta bancaria donde recibirás los fondos recaudados.",
+ "ibanPlaceholder": "ES00 0000 0000 0000 0000 0000",
+ "bankCertificate": "Certificado de titularidad de cuenta",
+ "bankCertificateHelper": "Adjunta el documento que acredita la titularidad de la cuenta bancaria.",
+ "bankCertificateAriaLabel": "Subir certificado de titularidad",
+ "ibanInvalid": "El formato del IBAN no es válido"
+ },
"continue": "Siguiente paso"
}
},
@@ -613,7 +665,8 @@
"length": "aportes",
"title": "Donaciones anónimas",
"description": "No todas las personas heróicas llevan capa ni tampoco sienten la necesidad de mostrar su identidad."
- }
+ },
+ "loadMore": "Ver más"
}
},
"rewards": {
@@ -848,7 +901,151 @@
},
"admin": {
"charges": {
- "title": "Cargos"
+ "title": "Aportes",
+ "description": "Aquí tienes las últimas contribuciones a campañas, puedes ordenarlas y filtrarlas como quieras.",
+ "headers": {
+ "target": "Destino",
+ "amount": "Importe",
+ "origin": "Origen",
+ "paymentMethod": "Método",
+ "date": "Fecha",
+ "chargeStatus": "Estado de la operación",
+ "refundToWallet": "A Monedero"
+ },
+ "noData": "No hay datos disponibles",
+ "details": {
+ "operationTime": "Hora de la operación",
+ "trackingCodes": {
+ "title": "Códigos de seguimiento",
+ "headers": {
+ "title": "Título",
+ "trackingCode": "Código de seguimiento"
+ }
+ },
+ "toWallet": "A monedero",
+ "platformLinks": {
+ "title": "Enlace a la plataforma",
+ "headers": {
+ "type": "Tipo de Enlace",
+ "rel": "Relación",
+ "href": "Url"
+ }
+ },
+ "estimatedFee": "Comisión estimada",
+ "concept": "Concepto"
+ },
+ "actions": {
+ "refundToWallet": "Devolución a monedero",
+ "refundToUser": "Devolución a usuario",
+ "changeUser": "Cambiar de usuario",
+ "manageReward": "Gestionar recompensa"
+ },
+ "filters": {
+ "order": {
+ "title": "Ordenar por:",
+ "options": {
+ "date-desc": "Fecha (más reciente primero)",
+ "date-asc": "Fecha (más antigua primero)",
+ "amount-desc": "Importe (mayor a menor)",
+ "amount-asc": "Importe (menor a mayor)",
+ "status-desc": "Estado (Z-A)",
+ "status-asc": "Estado (A-Z)"
+ }
+ },
+ "itemsPerPage": {
+ "title": "Líneas por página:",
+ "options": {
+ "10": "10",
+ "20": "20",
+ "30": "30",
+ "50": "50"
+ }
+ },
+ "search": {
+ "placeholder": "Buscar...",
+ "btnLabel": "Buscar",
+ "resultsFound": "{{totalItems}} resultados para \"{{query}}\"",
+ "noResults": "No se han encontrado resultados"
+ },
+ "paymentMethod": {
+ "title": "Método de pago",
+ "options": {
+ "all": "Todos",
+ "paypal": "Paypal",
+ "stripe": "Stripe",
+ "wallet": "Monedero"
+ }
+ },
+ "rangeAmount": {
+ "title": "Selecciona importe",
+ "options": {
+ "500000": "+ 5.000€",
+ "all": "Todos",
+ "000..10000": "0€ - 100€",
+ "10000..50000": "100€ - 500€",
+ "50000..100000": "500€ - 1.000€",
+ "100000..500000": "1.000€ - 5.000€"
+ }
+ },
+ "dateRange": {
+ "initDate": "Fecha Inicio",
+ "endDate": "Fecha Fin",
+ "from": "Desde",
+ "to": "Hasta",
+ "errors": {
+ "invalidDate": "Fecha no válida",
+ "invalidRange": "La fecha de inicio no puede ser mayor que la fecha fin."
+ }
+ },
+ "chargeStatus": {
+ "title": "Estado de Aporte",
+ "options": {
+ "all": "Todos",
+ "to_charge": "Por cobrar",
+ "in_charge": "Cobrado",
+ "to_refund": "Por reembolsar",
+ "refunded": "Reembolsado",
+ "to_wallet": "Por reembolsar a monedero",
+ "walleted": "Reembolsado a monedero"
+ }
+ },
+ "projectStatus": {
+ "title": "Estado de la campaña",
+ "options": {
+ "all": "Todas",
+ "active": "Activas",
+ "finished": "Finalizadas",
+ "cancelled": "Canceladas"
+ }
+ },
+ "btns": {
+ "openFilters": "Filtros",
+ "closeFilters": "Cerrar filtros",
+ "reset": "Reiniciar filtros",
+ "apply": "Aplicar filtros"
+ }
+ },
+ "pagination": {
+ "showing": "Mostrando {{items}} de {{total}}",
+ "unloaded": "Mostrando...",
+ "noResults": "No hay resultados"
+ },
+ "export": {
+ "csv": "Exportar CSV",
+ "exporting": "Exportando...",
+ "error": "Error al exportar el archivo CSV. Por favor, inténtalo de nuevo.",
+ "errorAuth": "Error de autenticación. Por favor, inicia sesión de nuevo.",
+ "filename": "gateway-charges",
+ "itemsText": "items",
+ "noData": "No se encontraron datos para exportar"
+ },
+ "downloadButtons": {
+ "details": "Descarga detalle",
+ "certificates": "Descarga certificados",
+ "pdf": "Descarga PDF"
+ },
+ "lastEdited": "Última modificación: {{date}} {{time}}",
+ "annotations": "{{annotations}} Anotaciones"
}
},
"static": {
@@ -859,7 +1056,7 @@
}
},
"system": {
- "_": "System messages (errors, notifications, validation, etc.)",
+ "_": "Mensajes del sistema (errores, notificaciones, validación, etc.)",
"loading": "Cargando...",
"notImplemented": "Esta funcionalidad aún no está implementada. Estamos trabajando en ello, ¡gracias por tu paciencia!",
"error": {
@@ -878,7 +1075,7 @@
"constraint": {
"file": {
"maxAllowedSize": "Tamaño máximo permitido 10MB",
- "supportedTypes": "Archivos compatibles: .png, .jpg"
+ "supportedTypes": "Archivos compatibles: {{ types }}"
}
},
"validation": {
@@ -1105,184 +1302,6 @@
"createProject": "Crea tu proyecto",
"goToProfile": "Ir a Perfil"
},
- "contributions": {
- "title": "Aportes",
- "description": "Aquí tienes las últimas contribuciones a campañas, puedes ordenarlas y filtrarlas como quieras.",
- "table": {
- "headers": {
- "target": "Destino",
- "amount": "Importe",
- "origin": "Origen",
- "paymentMethod": "Método",
- "date": "Fecha",
- "chargeStatus": "Estado de la operación",
- "refundToWallet": "A Monedero"
- },
- "rows": {
- "payments": {
- "tpv": "Tarjeta",
- "paypal": "Paypal",
- "stripe": "Stripe",
- "wallet": "Monedero"
- },
- "status": {
- "to_charge": "Por cobrar",
- "in_charge": "Cobrado",
- "to_refund": "Por reembolsar",
- "refunded": "Reembolsado",
- "to_wallet": "Por reembolsar a monedero",
- "walleted": "Reembolsado a monedero"
- },
- "refund": {
- "to_wallet": "Sí",
- "to_gateway": "No"
- },
- "noData": "No hay datos disponibles"
- }
- },
- "grid": {
- "details": {
- "operationTime": "Hora de la operación",
- "trackingCodes": {
- "title": "Códigos de seguimiento",
- "headers": {
- "title": "Título",
- "trackingCode": "Código de seguimiento"
- }
- },
- "toWallet": "A monedero",
- "platformLinks": {
- "title": "Enlace a la plataforma",
- "headers": {
- "type": "Tipo de Enlace",
- "rel": "Relación",
- "href": "Url"
- }
- },
- "estimatedFee": "Comisión estimada",
- "concept": "Concepto"
- },
- "actions": {
- "refundToWallet": "Devolución a monedero",
- "refundToUser": "Devolución a usuario",
- "changeUser": "Cambiar de usuario",
- "manageReward": "Gestionar recompensa"
- },
- "content-footer": {
- "lastEdited": "Última modificación: {{date}} {{time}}",
- "downloadDetails": "Descarga detalle",
- "annotations": "{{annotations}} Anotaciones"
- }
- },
- "filters": {
- "order": {
- "title": "Ordenar por:",
- "options": {
- "date-desc": "Fecha (más reciente primero)",
- "date-asc": "Fecha (más antigua primero)",
- "amount-desc": "Importe (mayor a menor)",
- "amount-asc": "Importe (menor a mayor)",
- "status-desc": "Estado (Z-A)",
- "status-asc": "Estado (A-Z)"
- }
- },
- "itemsPerPage": {
- "title": "Líneas por página:",
- "options": {
- "10": "10",
- "20": "20",
- "30": "30",
- "50": "50"
- }
- },
- "search": {
- "placeholder": "Buscar...",
- "btnLabel": "Buscar",
- "resultsFound": "{{totalItems}} resultados para \"{{query}}\"",
- "noResults": "No se han encontrado resultados",
- "labels": {
- "projects": "Proyectos",
- "users": "Usuarios",
- "tipjars": "Botes de propina",
- "tipjar-id": "ID del bote de propina"
- }
- },
- "paymentMethod": {
- "title": "Método de pago",
- "options": {
- "all": "Todos",
- "paypal": "Paypal",
- "stripe": "Stripe",
- "wallet": "Monedero"
- }
- },
- "rangeAmount": {
- "title": "Selecciona importe",
- "options": {
- "500000": "+ 5.000€",
- "all": "Todos",
- "000..10000": "0€ - 100€",
- "10000..50000": "100€ - 500€",
- "50000..100000": "500€ - 1.000€",
- "100000..500000": "1.000€ - 5.000€"
- }
- },
- "dateRange": {
- "initDate": "Fecha Inicio",
- "endDate": "Fecha Fin",
- "from": "Desde",
- "to": "Hasta",
- "errors": {
- "invalidDate": "Fecha no válida",
- "invalidRange": "La fecha de inicio no puede ser mayor que la fecha fin."
- }
- },
- "chargeStatus": {
- "title": "Estado de Aporte",
- "options": {
- "all": "Todos",
- "to_charge": "Por cobrar",
- "in_charge": "Cobrado",
- "to_refund": "Por reembolsar",
- "refunded": "Reembolsado",
- "to_wallet": "Por reembolsar a monedero",
- "walleted": "Reembolsado a monedero"
- }
- },
- "projectStatus": {
- "title": "Estado de la campaña",
- "options": {
- "all": "Todas",
- "active": "Activas",
- "finished": "Finalizadas",
- "cancelled": "Canceladas"
- }
- },
- "btns": {
- "openFilters": "Filtros",
- "closeFilters": "Cerrar filtros",
- "reset": "Reiniciar filtros",
- "apply": "Aplicar filtros"
- }
- },
- "pagination": {
- "showing": "Mostrando {{items}} de {{total}}",
- "unloaded": "Mostrando...",
- "noResults": "No hay resultados"
- },
- "tootip": {
- "copied": "¡Copiado!"
- },
- "export": {
- "csv": "Exportar CSV",
- "exporting": "Exportando...",
- "error": "Error al exportar el archivo CSV. Por favor, inténtalo de nuevo.",
- "errorAuth": "Error de autenticación. Por favor, inicia sesión de nuevo.",
- "filename": "gateway-charges",
- "itemsText": "items",
- "noData": "No se encontraron datos para exportar"
- }
- },
"admin": {
"projects": {
"totalizers": {
@@ -1291,14 +1310,6 @@
"totalPaid": "Total pagado:",
"totalUnpaid": "Falta por pagar:"
}
- },
- "charges": {
- "lastContributions": "Últimos aportes:",
- "totalizers": {
- "totalCharges": "Total aportes:",
- "totalTips": "Total propinas:",
- "totalFees": "Comisiones totales:"
- }
}
},
"footer": {
@@ -1347,6 +1358,10 @@
},
"profile": {
"share": "Compartir",
+ "shareModal": {
+ "title": "Comparte este perfil",
+ "description": "Puedes copiar el enlace para enviarlo y también compartirlo en Facebook o X."
+ },
"subscribe": "Subscríbete",
"tabs": {
"about": "Sobre el perfil",
@@ -1359,6 +1374,7 @@
"organizationIntro": "Organización comprometida con el cambio social",
"bioSample": "Con una trayectoria dedicada al ecosistema de crowdfunding y financiación colectiva, {{name}} se ha consolidado como un referente en la creación de proyectos colaborativos y la movilización de recursos. Su visión se centra en potenciar iniciativas que transforman realidades locales, combinando estrategias innovadoras con un profundo compromiso por la participación ciudadana y la equidad social.",
"noProjects": "No hay proyectos impulsados aún.",
+ "noBio": "Todavía no hay información sobre este perfil.",
"donorType": {
"fan": {
"title": "Fan",
@@ -1464,4 +1480,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/src/pages/[...locale]/admin/charges.astro b/src/pages/[...locale]/admin/charges.astro
index d22e2622..284132a2 100644
--- a/src/pages/[...locale]/admin/charges.astro
+++ b/src/pages/[...locale]/admin/charges.astro
@@ -11,9 +11,9 @@ import Contributions from "../../../components/Admin/Contributions.svelte";
- {t("contributions.title")}
+ {t("pages.admin.charges.title")}
-
{t("contributions.description")}
+
{t("pages.admin.charges.description")}
diff --git a/src/pages/[...locale]/checkout/post-payment.astro b/src/pages/[...locale]/checkout/post-payment.astro
index f977e074..e62e9c7b 100644
--- a/src/pages/[...locale]/checkout/post-payment.astro
+++ b/src/pages/[...locale]/checkout/post-payment.astro
@@ -15,18 +15,20 @@ import type { Campaign } from "../../../types/campaign";
const { t, lang, session } = Astro.locals;
-const mapProjectToCard = (project: Project): Campaign => ({
- slug: project.slug || "",
- title: project.title || "",
- subtitle: project.subtitle || "",
- description: project.description || "",
- image: project.video?.thumbnail || "/images/placeholder-project.jpg",
- obtained: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
- minimum: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
- categories: project.categories || [],
- territory: project.territory || "",
- size: "small",
-});
+const mapProjectToCard = (project: Project) =>
+ ({
+ id: project.slug || "",
+ slug: project.slug || "",
+ title: project.title || "",
+ subtitle: project.subtitle || "",
+ description: project.description || "",
+ image: project.video?.thumbnail || "/images/placeholder-project.jpg",
+ obtained: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
+ minimum: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
+ categories: project.categories || [],
+ territory: project.territory || "",
+ size: "small",
+ }) as unknown as Campaign;
let donatedProjects: Campaign[] = [];
let suggestedProjects: Campaign[] = [];
diff --git a/src/pages/[...locale]/create/CreateProjectForm.svelte b/src/pages/[...locale]/create/CreateProjectForm.svelte
index b7228f8a..24d58f98 100644
--- a/src/pages/[...locale]/create/CreateProjectForm.svelte
+++ b/src/pages/[...locale]/create/CreateProjectForm.svelte
@@ -1,15 +1,6 @@
-
+
{$t("pages.project.create.title")}
@@ -227,8 +298,10 @@
handleFieldBlur("title")}
+ onInput={(e) => handleFieldChange("title", (e.target as HTMLInputElement).value)}
/>
@@ -275,7 +350,7 @@
handleFieldBlur("release")}
@@ -290,38 +365,50 @@
{#if submitSuccess}
- Project draft created successfully! (Dev mode simulation)
+ {$t("pages.project.create.submitSuccess")}
{/if}
-
-
-
- {$draft.title || $t("pages.project.create.description.titlePlaceholder")}
+
+
+ {$currentDraft?.createProject.title ||
+ $t("pages.project.create.description.titlePlaceholder")}
-
- {$draft.subtitle || $t("pages.project.create.description.subtitlePlaceholder")}
+
+ {$currentDraft?.createProject.subtitle ||
+ $t("pages.project.create.description.subtitlePlaceholder")}
{$t("pages.project.create.budgetPreview")}
- {formatCurrency($draft.budget)}
+ {formatCurrency(
+ $currentDraft?.wizardForm.budget?.minimum?.money?.amount || 0,
+ "EUR",
+ )}
diff --git a/src/pages/[...locale]/create/project-draft.ts b/src/pages/[...locale]/create/project-draft.ts
deleted file mode 100644
index 3c1f2dff..00000000
--- a/src/pages/[...locale]/create/project-draft.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { derived, writable, get } from "svelte/store";
-import { z } from "zod";
-
-import { projectCreationSchema } from "./validation";
-
-export interface ProjectDraft {
- title: string;
- subtitle: string;
- categories: string[];
- budget: number;
- release: Date;
- termsAccepted: boolean;
-}
-
-// Default release date: 28 days from now
-const getDefaultReleaseDate = () => {
- const date = new Date();
- date.setDate(date.getDate() + 28);
- return date;
-};
-
-export const draft = writable({
- title: "",
- subtitle: "",
- categories: [],
- budget: 0, // Budget is displayed but not validated
- release: getDefaultReleaseDate(),
- termsAccepted: false,
-});
-
-/**
- * Validation errors store.
- * Maps field names to their error messages (translation keys).
- */
-export const validationErrors = writable>({});
-
-/**
- * Tracks which fields have been touched (blurred).
- * Only touched fields will show validation errors.
- */
-export const touchedFields = writable>(new Set());
-
-/**
- * Derived store that indicates if the form is valid.
- * Returns true only when:
- * 1. All required fields have values
- * 2. There are no validation errors
- */
-export const isFormValid = derived([draft, validationErrors], ([$draft, $errors]) => {
- // Check if there are any validation errors
- if (Object.keys($errors).length > 0) {
- return false;
- }
-
- // Check that required fields have values
- const hasTitle = $draft.title.trim().length > 0;
- const hasSubtitle = $draft.subtitle.trim().length > 0;
- const hasCategories = $draft.categories.length > 0;
- const hasTerms = $draft.termsAccepted === true;
-
- return hasTitle && hasSubtitle && hasCategories && hasTerms;
-});
-
-/**
- * Validates a single field and updates the validation errors store.
- * Uses Zod's type system with proper type guards to avoid unsafe type assertions.
- * @param fieldName - The name of the field to validate
- * @param value - The current value of the field
- */
-export function validateField(fieldName: keyof ProjectDraft, value: unknown) {
- // Type guard to check if field exists in schema
- type SchemaFields = keyof typeof projectCreationSchema.shape;
-
- if (!(fieldName in projectCreationSchema.shape)) {
- // Field not in schema, skip validation
- return;
- }
-
- // Now we know fieldName exists in the schema
- const fieldSchema = projectCreationSchema.shape[fieldName as SchemaFields];
-
- // Use Zod's ZodTypeAny for proper type handling
- const result = (fieldSchema as z.ZodTypeAny).safeParse(value);
-
- validationErrors.update((errors) => {
- if (!result.success) {
- // Extract the first error message
- const errorMessage = result.error.issues[0]?.message || "";
- return { ...errors, [fieldName]: errorMessage };
- } else {
- // Remove error if validation passes
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { [fieldName]: _, ...rest } = errors;
- return rest;
- }
- });
-}
-
-/**
- * Validates the entire form and updates the validation errors store.
- * Uses get() helper to avoid manual subscription management.
- * Returns true if the form is valid, false otherwise.
- */
-export function validateForm(): boolean {
- const currentDraft = get(draft);
- const result = projectCreationSchema.safeParse(currentDraft);
-
- if (!result.success) {
- const errors: Record = {};
- result.error.issues.forEach((issue) => {
- const fieldName = issue.path[0] as string;
- if (!errors[fieldName]) {
- errors[fieldName] = issue.message;
- }
- });
- validationErrors.set(errors);
- return false;
- } else {
- validationErrors.set({});
- return true;
- }
-}
-
-/**
- * Marks a field as touched.
- * @param fieldName - The name of the field that was touched
- */
-export function markFieldAsTouched(fieldName: string) {
- touchedFields.update((fields) => {
- const newFields = new Set(fields);
- newFields.add(fieldName);
- return newFields;
- });
-}
-
-/**
- * Resets the form to its initial state.
- */
-export function resetForm() {
- draft.set({
- title: "",
- subtitle: "",
- categories: [],
- budget: 0,
- release: getDefaultReleaseDate(),
- termsAccepted: false,
- });
- validationErrors.set({});
- touchedFields.set(new Set());
-}
diff --git a/src/pages/[...locale]/create/validation.ts b/src/pages/[...locale]/create/validation.ts
index 4984d15a..aca5fc32 100644
--- a/src/pages/[...locale]/create/validation.ts
+++ b/src/pages/[...locale]/create/validation.ts
@@ -69,10 +69,6 @@ export const projectCreationSchema = z.object({
* Not required for form submission but included for type consistency.
*/
budget: z.number().nonnegative().optional().default(0),
-
- termsAccepted: z.boolean().refine((val) => val === true, {
- message: "validation.project.terms.required",
- }),
});
/**
diff --git a/src/pages/[...locale]/profile/[idOrHandle].astro b/src/pages/[...locale]/profile/[idOrHandle].astro
deleted file mode 100644
index a9b9be3e..00000000
--- a/src/pages/[...locale]/profile/[idOrHandle].astro
+++ /dev/null
@@ -1,232 +0,0 @@
----
-import Layout from "../../../layouts/Layout.astro";
-import App from "../../../components/App.svelte";
-import ProfileHero from "../../../components/profile/ProfileHero.astro";
-import ProfileInfo from "../../../components/profile/ProfileInfo.svelte";
-import Tabs from "../../../components/Tabs.svelte";
-import ProfileProjectsList from "../../../components/profile/ProfileProjectsList.astro";
-import ProfileDonorType from "../../../components/profile/ProfileDonorType.svelte";
-import { renderMarkdown } from "../../../utils/renderMarkdown";
-import {
- apiUsersIdOrHandleGet,
- apiProjectSupportsGetCollection,
- apiProjectSupportsmoneyTotalGetCollection,
- type Accounting,
- type Money,
- type ProjectSupport,
-} from "../../../openapi/client";
-import type { User } from "../../../openapi/client/types.gen";
-import type { Campaign } from "../../../types/campaign";
-import { getLanguage } from "../../../middleware/utils";
-import { client } from "../../../openapi/client/client.gen";
-
-const { idOrHandle } = Astro.params;
-const { t, lang, session } = Astro.locals;
-
-if (!idOrHandle) {
- return Astro.redirect("/404");
-}
-
-// Fetch user data
-let user: User | undefined;
-let errorMessage: string | undefined;
-
-try {
- const userResponse = await apiUsersIdOrHandleGet({
- path: { idOrHandle: idOrHandle },
- });
-
- if (userResponse.error) {
- throw new Error("User not found");
- }
-
- user = userResponse.data;
-} catch (error) {
- console.error("Error fetching user:", error);
- errorMessage = t("profile.notFound");
-}
-
-// Prepare profile data with proper fallbacks
-const displayName = user?.displayName || user?.handle || "User";
-
-// Location - API doesn't have location field yet
-// TODO: Add location field to Person/Organization in API
-const location = undefined;
-
-// Bio - API doesn't have bio/description field yet
-// TODO: Add bio/description field to User in API
-const bio = undefined;
-
-const tabs = [
- { id: "about", label: t("profile.tabs.about") },
- { id: "projects", label: t("profile.tabs.projects") },
- { id: "donorType", label: t("profile.tabs.donorType") },
-];
-
-// Fetch user's backed/supported projects from API
-let userSupports: ProjectSupport[] = [];
-let totalDonated: { amount: number; currency: string } = { amount: 0, currency: "EUR" };
-let projectsDonatedCount = 0;
-
-// Users are guaranteed to have an accounting resource
-if (user) {
- try {
- // Get all project supports for this user
- const { data: supports } = await apiProjectSupportsGetCollection({
- query: {
- origin: user.accounting!,
- },
- });
-
- if (supports) {
- userSupports = supports;
- projectsDonatedCount = supports.length;
-
- // Get total money donated
- const { data: moneyTotal } = await apiProjectSupportsmoneyTotalGetCollection({
- query: {
- origin: user.accounting!,
- },
- });
-
- if (moneyTotal?.amount && moneyTotal?.currency) {
- totalDonated = {
- amount: Number(moneyTotal.amount),
- currency: moneyTotal.currency,
- };
- }
- }
- } catch (error) {
- console.error("Error fetching user supports:", error);
- }
-}
-
-// Fetch actual projects from the supports
-const projectIRIs = userSupports
- .slice(0, 10)
- .map((support) => support.project)
- .filter(Boolean);
-let campaigns: Campaign[] = [];
-
-if (projectIRIs.length > 0) {
- campaigns = await Promise.all(
- projectIRIs.map(async (projectIRI) => {
- try {
- // Fetch project data directly using IRI
- const { data: project } = (await client.get({
- url: client.buildUrl({ url: projectIRI! }),
- headers: {
- "Accept-Language": getLanguage(Astro),
- },
- })) as { data: any };
-
- // Fetch accounting data
- const { data: accounting } = (await client.get({
- url: client.buildUrl({ url: project.accounting! }),
- })) as { data: Accounting };
-
- return {
- size: "small",
- id: project.slug!,
- title: project.title!,
- image: project.video?.thumbnail!,
- minimum: project.budget?.minimum?.money!,
- obtained: accounting!.balance as Money,
- optimum: project.budget?.optimum?.money,
- };
- } catch (error) {
- console.error(`Error fetching project ${projectIRI}:`, error);
- return null;
- }
- }),
- ).then((results) => results.filter(Boolean) as Campaign[]);
-}
-
-// Social links - would come from user.socialMedia or similar field
-// API doesn't have social media fields yet
-// TODO: Add social media fields to User in API
-const socialLinks = user
- ? {
- twitter: undefined,
- instagram: undefined,
- facebook: undefined,
- linkedin: undefined,
- email: user.email ? `mailto:${user.email}` : undefined,
- medium: undefined,
- }
- : {};
-
-// Images with fallbacks
-const coverImage = undefined; // API doesn't provide cover image, use default
-const avatarImage = user?.avatar || undefined; // Use user avatar or default placeholder
----
-
-
-
- {
- errorMessage ? (
-
- ) : (
-
- {/* Hero Section with Cover and Avatar */}
-
-
- {/* User Info Section */}
-
-
- {/* Tabs Navigation */}
-
-
-
-
- {/* Tab Content - About */}
-
-
- {/* Tab Content - Projects */}
-
- {campaigns.length > 0 ? (
-
- ) : (
-
-
{t("profile.noProjects")}
-
- )}
-
-
- {/* Tab Content - Donor Type */}
-
-
- )
- }
-
-
diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte
index e3bad112..4c702108 100644
--- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte
+++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte
@@ -8,38 +8,43 @@
- URL query parameter sync
-->
-
- {@const StepComponent = getStepComponent(currentStep)}
-
-
+{#if isInitialized && resolvedProject}
+
+ {@const StepComponent = getStepComponent(currentStep)}
+
+
+{/if}
diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte
index 971fda17..1c54e0af 100644
--- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte
+++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte
@@ -17,17 +17,22 @@
-->
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if user.avatar}
+

+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if user.description}
+ {#await renderMarkdown(user.description) then html}
+
+ {@html html}
+
+ {/await}
+ {:else}
+
+ {$t("profile.noBio")}
+
+ {/if}
+
+
+
+
+ {#if campaigns.length > 0}
+
+ {#each campaigns as campaign}
+
+ {/each}
+
+ {:else}
+
{$t("profile.noProjects")}
+ {/if}
+
+
+
+
+
diff --git a/src/pages/[...locale]/user/[idOrHandle]/index.astro b/src/pages/[...locale]/user/[idOrHandle]/index.astro
new file mode 100644
index 00000000..cf7f23d2
--- /dev/null
+++ b/src/pages/[...locale]/user/[idOrHandle]/index.astro
@@ -0,0 +1,116 @@
+---
+import User from "./User.svelte";
+import Layout from "../../../../layouts/Layout.astro";
+import App from "../../../../components/App.svelte";
+import {
+ apiUsersIdOrHandleGet,
+ apiProjectSupportsGetCollection,
+ apiProjectSupportsmoneyTotalGetCollection,
+ apiProjectsGetCollection,
+} from "../../../../openapi/client";
+import type { Money, ProjectSupport } from "../../../../openapi/client";
+import { apiProjectSupportsGetCollectionUrl } from "../../../../openapi/client/paths.gen";
+import type { Campaign } from "../../../../types/campaign";
+import { client } from "../../../../openapi/client/client.gen";
+
+const { idOrHandle } = Astro.params;
+const { lang, session } = Astro.locals;
+
+if (!idOrHandle) {
+ return Astro.redirect("/404");
+}
+
+const { data: user, error } = await apiUsersIdOrHandleGet({
+ path: { idOrHandle },
+});
+
+if (error || !user) {
+ return Astro.redirect("/404");
+}
+
+if (!user.accounting) {
+ throw new Error(`User ${idOrHandle} has no Accounting`);
+}
+
+type ProjectSupportsCollection = { member: ProjectSupport[]; totalItems: number };
+
+let userSupports: ProjectSupport[] = [];
+let totalDonated = { amount: 0, currency: import.meta.env.PUBLIC_DEFAULT_CURRENCY as string };
+let projectsDonatedCount = 0;
+
+try {
+ const [supportsResponse, supportsTotal] = await Promise.all([
+ apiProjectSupportsGetCollection({ query: { origin: user.accounting } }),
+ client.get({
+ url: client.buildUrl({
+ url: apiProjectSupportsGetCollectionUrl,
+ query: { origin: user.accounting, itemsPerPage: 1 },
+ }),
+ headers: { Accept: "application/ld+json" },
+ }) as unknown as Promise<{ data: ProjectSupportsCollection }>,
+ ]);
+
+ userSupports = supportsResponse.data ?? [];
+ projectsDonatedCount = supportsTotal.data?.totalItems ?? userSupports.length;
+
+ if (userSupports.length > 0) {
+ const { data: moneyTotal } = await apiProjectSupportsmoneyTotalGetCollection({
+ query: { origin: user.accounting },
+ });
+
+ if (moneyTotal?.amount && moneyTotal?.currency) {
+ totalDonated = {
+ amount: Number(moneyTotal.amount),
+ currency: moneyTotal.currency,
+ };
+ }
+ }
+} catch (e) {
+ console.error("Error fetching user supports:", e);
+}
+
+const slugs = userSupports
+ .slice(0, 10)
+ .map((s) => s.project?.split("/").at(-1))
+ .filter(Boolean) as string[];
+
+let campaigns: Campaign[] = [];
+
+if (slugs.length > 0) {
+ try {
+ const { data: projects } = await apiProjectsGetCollection({
+ query: { "slug[]": slugs },
+ headers: { "Accept-Language": lang },
+ });
+
+ if (projects) {
+ campaigns = projects
+ .map((project) => ({
+ ...project,
+ slug: project.slug!,
+ title: project.title!,
+ image: project.video?.thumbnail ?? "",
+ minimum: project.budget?.minimum?.money as Money,
+ optimum: project.budget?.optimum?.money as Money | undefined,
+ size: "small",
+ }))
+ .filter((c) => c.slug && c.title) as Campaign[];
+ }
+ } catch (e) {
+ console.error("Error fetching projects:", e);
+ }
+}
+---
+
+
+
+
+
+
diff --git a/src/pages/api/relay/[...path].ts b/src/pages/api/relay/[...path].ts
index a5bd59ca..eeed2123 100644
--- a/src/pages/api/relay/[...path].ts
+++ b/src/pages/api/relay/[...path].ts
@@ -1,7 +1,19 @@
-import { getBaseUrl } from "../../../utils/consts";
+import { getSession } from "../../../auth/session";
import type { APIRoute } from "astro";
+// Whitelist of safe response headers to forward to the client
+const SAFE_RESPONSE_HEADERS = [
+ "content-type",
+ "content-language",
+ "content-length",
+ "cache-control",
+ "etag",
+ "last-modified",
+ "expires",
+ "vary",
+];
+
/**
* API Relay Endpoint
*
@@ -14,94 +26,53 @@ import type { APIRoute } from "astro";
*
* Usage from client:
* ```ts
- * const response = await fetch('/api/relay/v4/users/123', {
- * method: 'GET',
- * headers: { 'Accept-Language': 'en' }
- * });
+ * const { data, err } = await apiProjectsPost({ baseUrl: '/api/relay', ... });
+ * const response = await fetch('/api/relay/v4/users/123', ...;
* ```
*/
export const ALL: APIRoute = async ({ request, cookies, params }) => {
- // Extract the API path from the catch-all parameter
- const apiPath = params.path || "";
-
- // Get the access token from cookies
- const accessToken = cookies.get("access-token")?.value;
-
- if (!accessToken) {
- return new Response(
- JSON.stringify({ error: "Unauthorized", message: "No access token found" }),
- {
- status: 401,
- headers: { "Content-Type": "application/json" },
- },
- );
- }
-
try {
- // Parse the token to get the actual bearer token
- const tokenData = JSON.parse(accessToken);
- const bearerToken = tokenData.token || accessToken;
+ const query = new URL(request.url).search;
+ const url = `${import.meta.env.PUBLIC_API_URL}/${params.path}${query}`;
- // Build the target URL
- const url = new URL(request.url);
- const targetUrl = `${getBaseUrl()}/${apiPath}${url.search}`;
+ const reqHeaders = new Headers(request.headers);
+ reqHeaders.delete("host");
- // Prepare headers
- const headers = new Headers(request.headers);
- headers.set("Authorization", `Bearer ${bearerToken}`);
-
- // Remove host header to avoid conflicts
- headers.delete("host");
-
- // Prepare request body for methods that support it
- let body: string | null = null;
- if (request.method === "POST" || request.method === "PUT" || request.method === "PATCH") {
- body = await request.text();
+ const session = await getSession(cookies);
+ if (session && session.token.asHttpHeaders) {
+ for (const [key, value] of Object.entries(session.token.asHttpHeaders)) {
+ reqHeaders.set(key, value);
+ }
}
- // Forward the request to the actual API
- const response = await fetch(targetUrl, {
+ let reqBody: string | null = null;
+ if (request.method === "POST" || request.method === "PUT" || request.method === "PATCH") {
+ reqBody = await request.text();
+ }
+
+ const response = await fetch(url, {
method: request.method,
- headers,
- body,
+ headers: reqHeaders,
+ body: reqBody,
});
- // Get the response body
- const responseText = await response.text();
+ const resBody = await response.text();
- // Whitelist of safe response headers to forward to the client
- // These headers are important for caching, content negotiation, and proper HTTP semantics
- const SAFE_RESPONSE_HEADERS = [
- "content-type",
- "content-language",
- "content-length",
- "cache-control",
- "etag",
- "last-modified",
- "expires",
- "vary",
- "link", // For pagination
- "x-total-count", // Common in REST APIs for total item count
- ];
-
- // Build response headers by copying whitelisted headers from the API response
- const responseHeaders = new Headers();
+ const resHeaders = new Headers();
response.headers.forEach((value, key) => {
if (SAFE_RESPONSE_HEADERS.includes(key.toLowerCase())) {
- responseHeaders.set(key, value);
+ resHeaders.set(key, value);
}
});
- // Ensure Content-Type is always set (fallback to application/json)
- if (!responseHeaders.has("content-type")) {
- responseHeaders.set("content-type", "application/json");
+ if (!resHeaders.has("content-type")) {
+ resHeaders.set("content-type", "application/json");
}
- // Return the response back to the client
- return new Response(responseText, {
+ return new Response(resBody, {
status: response.status,
statusText: response.statusText,
- headers: responseHeaders,
+ headers: resHeaders,
});
} catch (error) {
console.error("API Relay Error:", error);
diff --git a/src/services/projectsService.ts b/src/services/projectsService.ts
index 37e4cea3..07d8d51d 100644
--- a/src/services/projectsService.ts
+++ b/src/services/projectsService.ts
@@ -28,14 +28,7 @@ export class ProjectsService {
try {
const response = await apiProjectsGetCollection({
query: {
- // Text search
- ...(filters.query?.trim() && { title: filters.query.trim() }),
- // Categories (array filter for OR logic) - Fixed: use "categories[]" not "category[]"
- ...(filters.categories?.length && { "categories[]": filters.categories }),
- // Status filter
- ...(filters.statusFilter &&
- filters.statusFilter !== "all" && { status: filters.statusFilter }),
- // Pagination
+ ...filters,
page: options?.page || 1,
itemsPerPage: options?.limit || 20,
},
diff --git a/src/stores/chargesPaginationAndSort.ts b/src/stores/chargesPaginationAndSort.ts
index 9af2af64..13fe2b4f 100644
--- a/src/stores/chargesPaginationAndSort.ts
+++ b/src/stores/chargesPaginationAndSort.ts
@@ -17,24 +17,24 @@ export const sortOptions: SortOption[] = [
key: "date-desc",
field: "dateCreated",
direction: "desc",
- label: "contributions.filters.order.options.date-desc",
+ label: "pages.admin.charges.filters.order.options.date-desc",
},
{
key: "date-asc",
field: "dateCreated",
direction: "asc",
- label: "contributions.filters.order.options.date-asc",
+ label: "pages.admin.charges.filters.order.options.date-asc",
},
{
key: "amount-desc",
field: "money.amount",
direction: "desc",
- label: "contributions.filters.order.options.amount-desc",
+ label: "pages.admin.charges.filters.order.options.amount-desc",
},
{
key: "amount-asc",
field: "money.amount",
direction: "asc",
- label: "contributions.filters.order.options.amount-asc",
+ label: "pages.admin.charges.filters.order.options.amount-asc",
},
];
diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts
new file mode 100644
index 00000000..7fc551df
--- /dev/null
+++ b/src/stores/drafts/draftValidation.ts
@@ -0,0 +1,301 @@
+import murmur from "murmurhash-js";
+import z from "zod";
+
+import { type Draft, type Wizard } from "./projectDraft";
+import { projectCreationSchema } from "../../pages/[...locale]/create/validation";
+
+import type {
+ ProjectBudgetItem,
+ ProjectCollaboration,
+ ProjectProjectCreationDto,
+ ProjectReward,
+} from "../../openapi/client";
+
+export type ValidationErrors = Record;
+
+/**
+ * Validates the entire create project form and updates the validation errors store.
+ * Returns true if the form is valid, false otherwise.
+ */
+export function validateCreateForm(createProject: ProjectProjectCreationDto): ValidationErrors {
+ const result = projectCreationSchema.safeParse(createProject);
+
+ if (!result.success) {
+ const errors: ValidationErrors = {};
+
+ result.error.issues.forEach((issue) => {
+ const fieldName = issue.path[0] as string;
+
+ if (!errors[fieldName]) {
+ errors[fieldName] = issue.message;
+ }
+ });
+
+ return errors;
+ }
+
+ return {};
+}
+
+export function validateDraftToPublish(draft: Draft): ValidationErrors {
+ const wizard = draft.wizardForm;
+
+ const errors: ValidationErrors = {};
+
+ Object.assign(errors, validateCreateForm(draft.createProject));
+
+ Object.assign(errors, validateConfiguration(wizard));
+
+ Object.assign(errors, validateCampaignInfo(wizard));
+
+ for (const reward of wizard.rewards) {
+ Object.assign(errors, validateReward(reward));
+ }
+
+ for (const collab of wizard.collaborations) {
+ Object.assign(errors, validateCollaboration(collab));
+ }
+
+ for (const minimumItem of wizard.budgetItems.minimum) {
+ Object.assign(errors, validateBudgetItem(minimumItem));
+ }
+
+ for (const optimumItem of wizard.budgetItems.optimum) {
+ Object.assign(errors, validateBudgetItem(optimumItem));
+ }
+
+ Object.assign(errors, validateBudgetAmount(draft));
+
+ return errors;
+}
+
+export function stripHtml(html: string): string {
+ if (typeof document === "undefined") {
+ return html.replace(/<[^>]*>/g, "").trim();
+ }
+ const div = document.createElement("div");
+ div.innerHTML = html;
+ return div.textContent || div.innerText || "";
+}
+
+/**
+ * Validation schema for Configuration step
+ */
+export const configurationSchema = z.object({
+ projectDeadline: z.enum(["minimum", "optimum"]),
+});
+
+/**
+ * Validate Configuration step (Step 1)
+ *
+ * Validates against the configurationSchema using Zod.
+ * Updates validationErrors store with any validation failures.
+ *
+ * @returns Record of field errors (empty if valid)
+ */
+export function validateConfiguration(wizard: Wizard): ValidationErrors {
+ const result = configurationSchema.safeParse(wizard.configuration);
+
+ if (!result.success) {
+ const errors: ValidationErrors = {};
+
+ result.error.issues.forEach((issue) => {
+ const fieldName = issue.path[0] as string;
+
+ if (!errors[fieldName]) {
+ errors[fieldName] = issue.message;
+ }
+ });
+
+ return errors;
+ }
+
+ return {};
+}
+
+/**
+ * Validate Campaign Info step (Step 2)
+ *
+ * Validates all campaign info fields according to requirements:
+ * - At least 1 image OR 1 video required
+ * - Objectives: min 50, max 5000 characters (plain text)
+ * - Legacy: min 50, max 5000 characters
+ * - Target audience: min 30, max 5000 characters
+ * - Team: min 50, max 5000 characters
+ *
+ * @returns Record of field errors (empty if valid)
+ *
+ * @example
+ * // Validate before completing step
+ * function handleContinue() {
+ * const errors = validateCampaignInfo();
+ * if (Object.keys(errors).length === 0) {
+ * completeCurrentStep();
+ * }
+ * }
+ */
+export function validateCampaignInfo(wizard: Wizard): ValidationErrors {
+ const data = wizard.campaignInfo;
+ const errors: ValidationErrors = {};
+
+ // Media validation
+ if (data.images.length === 0 && !data.video) {
+ errors.media = "pages.project.edit.rewards.validation_info.reward.media.required";
+ }
+
+ // Objectives validation
+ const objectivesPlainText = stripHtml(data.objectives).trim();
+ if (objectivesPlainText.length === 0) {
+ errors.objectives = "pages.project.edit.rewards.validation_info.reward.objectives.required";
+ } else if (objectivesPlainText.length < 50) {
+ errors.objectives =
+ "pages.project.edit.rewards.validation_info.reward.objectives.min_length";
+ } else if (objectivesPlainText.length > 5000) {
+ errors.objectives =
+ "pages.project.edit.rewards.validation_info.reward.objectives.max_length";
+ }
+
+ // Legacy validation
+ const legacyPlainText = stripHtml(data.legacy).trim();
+ if (legacyPlainText.length === 0) {
+ errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.required";
+ } else if (legacyPlainText.length < 50) {
+ errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.min_length";
+ } else if (legacyPlainText.length > 5000) {
+ errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.max_length";
+ }
+
+ // Target audience validation
+ const targetPlainText = stripHtml(data.targetAudience).trim();
+ if (targetPlainText.length === 0) {
+ errors.targetAudience = "pages.project.edit.rewards.validation_info.reward.target.required";
+ } else if (targetPlainText.length < 30) {
+ errors.targetAudience =
+ "pages.project.edit.rewards.validation_info.reward.target.min_length";
+ } else if (targetPlainText.length > 5000) {
+ errors.targetAudience =
+ "pages.project.edit.rewards.validation_info.reward.target.max_length";
+ }
+
+ // Team validation
+ const teamPlainText = stripHtml(data.team).trim();
+ if (teamPlainText.length === 0) {
+ errors.team = "pages.project.edit.rewards.validation_info.reward.team.required";
+ } else if (teamPlainText.length < 50) {
+ errors.team = "pages.project.edit.rewards.validation_info.reward.team.min_length";
+ } else if (teamPlainText.length > 5000) {
+ errors.team = "pages.project.edit.rewards.validation_info.reward.team.max_length";
+ }
+
+ return errors;
+}
+
+export function validateReward(reward: ProjectReward): ValidationErrors {
+ const errors: ValidationErrors = {};
+ const hash = murmur.murmur3(JSON.stringify(reward));
+
+ if (!reward.title.trim()) {
+ errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title";
+ }
+
+ if (!reward.money.amount || reward.money.amount <= 0) {
+ errors[`reward_error_amount_${hash}`] = "pages.project.edit.rewards.validation.amount";
+ }
+
+ if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) {
+ errors[`reward_error_units_${hash}`] = "pages.project.edit.rewards.validation.units";
+ }
+
+ return errors;
+}
+
+export function validateCollaboration(collab: ProjectCollaboration): ValidationErrors {
+ const errors: ValidationErrors = {};
+ const hash = murmur.murmur3(JSON.stringify(collab));
+
+ if (!collab.title.trim()) {
+ errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title";
+ }
+
+ if (collab.description && collab.description.length > 1000) {
+ errors[`collab_error_description_too_long_${hash}`] =
+ "pages.project.edit.collaborations.validation.descriptionTooLong";
+ }
+
+ if (!collab.description.trim()) {
+ errors[`collab_error_description_${hash}`] =
+ "pages.project.edit.collaborations.validation.description";
+ }
+
+ return errors;
+}
+
+export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors {
+ const errors: ValidationErrors = {};
+ const hash = murmur.murmur3(JSON.stringify(item));
+
+ if (!item.title.trim()) {
+ errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title.";
+ }
+
+ if (!item.description.trim()) {
+ errors[`budget_error_description_${hash}`] =
+ "pages.project.edit.budget.validation.description";
+ }
+
+ if (!item.money.amount || item.money.amount <= 0) {
+ errors[`budget_error_amount_${hash}`] = "pages.project.edit.budget.validation.amount";
+ }
+
+ if (!item.money.currency) {
+ errors[`budget_error_currency_${hash}`] = "pages.project.edit.budget.validation.currency";
+ }
+
+ if (!item.type) {
+ errors[`budget_error_type_${hash}`] = "pages.project.edit.budget.validation.type";
+ }
+
+ if (!item.deadline || (item.deadline !== "minimum" && item.deadline !== "optimum")) {
+ errors[`budget_error_deadline_${hash}`] = "pages.project.edit.budget.validation.class";
+ }
+
+ return errors;
+}
+
+export function validateBudgetAmount(draft: Draft) {
+ const budgetItems = draft.wizardForm.budgetItems;
+ const errors: ValidationErrors = {};
+
+ if (budgetItems.minimum.length <= 0) {
+ errors.minimum_length = "pages.project.edit.budget.validation.minimumItemsLength";
+ }
+
+ return errors;
+}
+
+/**
+ * Validates a single field and updates the validation errors store.
+ * Uses Zod's type system with proper type guards to avoid unsafe type assertions.
+ * @param fieldName - The name of the field to validate
+ * @param value - The current value of the field
+ */
+export function validateField(
+ fieldName: keyof ProjectProjectCreationDto,
+ value: unknown,
+): string | null {
+ type SchemaFields = keyof typeof projectCreationSchema.shape;
+
+ if (!(fieldName in projectCreationSchema.shape)) {
+ return null;
+ }
+
+ const fieldSchema = projectCreationSchema.shape[fieldName as SchemaFields];
+
+ const result = (fieldSchema as z.ZodTypeAny).safeParse(value);
+
+ if (!result.success) {
+ return result.error.issues[0]?.message || "";
+ }
+
+ return null;
+}
diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts
new file mode 100644
index 00000000..1191511e
--- /dev/null
+++ b/src/stores/drafts/projectDraft.ts
@@ -0,0 +1,776 @@
+import { liveQuery } from "dexie";
+import { writable, readable, get, derived } from "svelte/store";
+
+import {
+ validateBudgetItem,
+ validateCollaboration,
+ validateDraftToPublish,
+ validateReward,
+ type ValidationErrors,
+} from "./draftValidation";
+import { session } from "../../auth/store";
+import { db } from "../../utils/drafts/db";
+import { draftRepo } from "../../utils/drafts/repository";
+
+import type {
+ Budget,
+ ProjectBudgetItem,
+ ProjectCollaboration,
+ ProjectProjectCreationDto,
+ ProjectReward,
+} from "../../openapi/client";
+
+/**
+ * Media image data
+ */
+export interface MediaImage {
+ id: string;
+ url: string; // Base64 data URL or API URL
+ file?: File; // Original file reference
+ size: number; // File size in bytes
+ name: string; // Original filename
+}
+
+export interface WizardConfiguration {
+ projectDeadline: "minimum" | "optimum"; // Default: minimum
+}
+
+export interface WizardCampaignInfo {
+ // Media/**
+ images: MediaImage[];
+ video: string | undefined;
+
+ // Rich text content (stored as HTML)
+ objectives: string;
+ legacy: string;
+ targetAudience: string;
+ team: string;
+}
+
+export type Wizard = {
+ // Step navigation
+ currentStep: number;
+
+ // Total budget amount
+ budget?: Budget;
+
+ // Step 1: Configuration
+ configuration: WizardConfiguration;
+
+ // Step 2: Campaign Information
+ campaignInfo: WizardCampaignInfo;
+
+ // Step 3: Rewards
+ rewards: ProjectReward[];
+
+ // Step 4: Collaborations
+ collaborations: ProjectCollaboration[];
+
+ // Step 5: Budget
+ budgetItems: {
+ minimum: ProjectBudgetItem[];
+ optimum: ProjectBudgetItem[];
+ };
+
+ // Pending future step (Phase 6) - placeholders
+ // aboutYou: WizardAboutYou;
+};
+
+export interface CreateProjectForm extends ProjectProjectCreationDto {
+ budget?: Budget;
+}
+
+export interface ProjectDraftResources {
+ rewards: ProjectReward[];
+ collaborations: ProjectCollaboration[];
+ budgetItems: {
+ minimum: ProjectBudgetItem[];
+ optimum: ProjectBudgetItem[];
+ };
+}
+
+export interface Draft {
+ draftId: string;
+ userId: number;
+ isDirty?: boolean;
+ apiSnapshot?: ProjectDraftResources;
+
+ createProject: CreateProjectForm;
+ wizardForm: Wizard;
+
+ updatedAt: Date;
+}
+
+export const drafts = derived(session, ($session, set) => {
+ if (!$session?.user?.id) {
+ set([]);
+ return;
+ }
+
+ const store = setDraftsStore($session.user.id);
+
+ const unsubscribe = store.subscribe(set);
+
+ return unsubscribe;
+});
+
+export const currentDraft = writable(null);
+
+export const wizard = derived(
+ currentDraft,
+ ($d) =>
+ $d?.wizardForm ??
+ ({
+ currentStep: 1,
+ configuration: {
+ projectDeadline: "minimum",
+ },
+ campaignInfo: {
+ images: [],
+ video: "",
+ objectives: "",
+ legacy: "",
+ targetAudience: "",
+ team: "",
+ },
+ rewards: [],
+ collaborations: [],
+ budgetItems: {
+ minimum: [],
+ optimum: [],
+ },
+ } as Wizard),
+);
+export const project = derived(
+ currentDraft,
+ ($d) =>
+ $d?.createProject ?? {
+ title: "",
+ subtitle: "",
+ categories: [],
+ release: undefined,
+ },
+);
+
+/**
+ * Touched fields tracker
+ */
+export const touchedFields = writable>(new Set());
+
+/**
+ * Store to track if there are unsaved changes in the current draft to send it to API
+ * Used to enable/disable the Save button in wizard form
+ */
+export const hasUnsavedChanges = writable(false);
+
+/**
+ * Store to track if the draft is currently being saved to IndexedDB (persistence in progress)
+ */
+export const isSavingDraft = writable(false);
+
+/**
+ * Persistence error state
+ * Tracks localStorage save failures (quota exceeded, general errors)
+ */
+export const persistenceError = writable(null);
+
+/**
+ * Validation errors store
+ * Stores field-specific validation error messages for the current step
+ * Updated on each field change and used to provide real-time validation feedback in the UI
+ */
+export const validationErrors = writable({});
+
+/**
+ * Derived store that validates the draft for publishing.
+ * Returns an object with validation errors for each field, or an empty object if valid.
+ */
+export const publishErrors = derived(currentDraft, ($draft) => {
+ if (!$draft) {
+ return {};
+ }
+
+ const errors = validateDraftToPublish(get(currentDraft) as Draft);
+ return errors;
+});
+
+/**
+ * Define whether the project is ready to publish (all steps completed and valid).
+ * Used to enable/disable the Publish button in the wizard UI
+ */
+export const isReadyToPublish = derived([publishErrors, currentDraft], ([$errors, $draft]) => {
+ if (!$draft) return false;
+
+ return Object.keys($errors).length === 0;
+});
+
+/**
+ * Derived store that indicates if the form is valid.
+ * Returns true only when:
+ * 1. All required fields have values
+ * 2. There are no validation errors
+ */
+export const isCreateFormValid = derived([currentDraft, validationErrors], ([$draft, $errors]) => {
+ if (Object.keys($errors).length > 0) {
+ return false;
+ }
+
+ const hasTitle = ($draft?.createProject?.title?.trim().length ?? 0) > 0;
+
+ const hasSubtitle = ($draft?.createProject?.subtitle?.trim().length ?? 0) > 0;
+
+ const hasCategories = ($draft?.createProject?.categories?.length ?? 0) > 0;
+
+ return hasTitle && hasSubtitle && hasCategories;
+});
+
+export function createDraftId(): string {
+ return crypto.randomUUID();
+}
+
+function getUserId(): number {
+ const s = get(session);
+
+ if (!s?.user?.id) {
+ throw new Error("User not authenticated");
+ }
+
+ return s.user.id;
+}
+
+export function setDraftsStore(userId: number) {
+ return readable([], (set) => {
+ const subscription = liveQuery(() =>
+ db.drafts.where("userId").equals(userId).reverse().sortBy("updatedAt"),
+ ).subscribe({
+ next: set,
+ error: console.error,
+ });
+
+ return () => subscription.unsubscribe();
+ });
+}
+
+export async function createDraft(
+ project?: ProjectProjectCreationDto,
+ draftId = createDraftId(),
+ isDirty = true,
+ resources?: ProjectDraftResources,
+) {
+ const userId = getUserId();
+
+ const draft: Draft = {
+ draftId,
+ userId,
+ isDirty,
+ apiSnapshot: resources,
+ createProject: project ?? ({} as Partial as CreateProjectForm),
+ wizardForm: {
+ currentStep: 1,
+ configuration: {
+ projectDeadline: "minimum",
+ },
+ campaignInfo: {
+ images: [],
+ video: "",
+ objectives: "",
+ legacy: "",
+ targetAudience: "",
+ team: "",
+ },
+ rewards: resources?.rewards ?? [],
+ collaborations: resources?.collaborations ?? [],
+ budgetItems: resources?.budgetItems ?? {
+ minimum: [],
+ optimum: [],
+ },
+ },
+ updatedAt: new Date(),
+ };
+
+ draft.createProject.categories = Object.values(project?.categories || []);
+
+ await draftRepo.create(draft);
+ currentDraft.set(draft);
+
+ return draftId;
+}
+
+export async function loadDraft(draftId: string) {
+ const draft = await draftRepo.get(draftId);
+
+ if (!draft) return false;
+
+ currentDraft.set(draft);
+ hasUnsavedChanges.set(draft.isDirty ?? true);
+ return true;
+}
+
+export async function initializeProjectDraft(
+ project: ProjectProjectCreationDto,
+ draftId: string,
+ resources?: ProjectDraftResources,
+) {
+ const loadedDraft = await draftRepo.get(draftId);
+
+ if (loadedDraft) {
+ const isDirty = loadedDraft.isDirty ?? true;
+ const nextDraft: Draft = {
+ ...loadedDraft,
+ apiSnapshot: resources ?? loadedDraft.apiSnapshot,
+ createProject: isDirty ? loadedDraft.createProject : project,
+ wizardForm:
+ !isDirty && resources
+ ? {
+ ...loadedDraft.wizardForm,
+ rewards: resources.rewards,
+ collaborations: resources.collaborations,
+ budgetItems: resources.budgetItems,
+ }
+ : loadedDraft.wizardForm,
+ updatedAt: new Date(),
+ };
+
+ currentDraft.set(nextDraft);
+ hasUnsavedChanges.set(isDirty);
+
+ return true;
+ }
+
+ await createDraft(project, draftId, false, resources);
+ hasUnsavedChanges.set(false);
+ return false;
+}
+
+/**
+ * Mark a field as touched (for validation UX)
+ *
+ * Used to track which fields the user has interacted with,
+ * so validation errors only show after user touches the field.
+ *
+ * @param fieldName - The field identifier to mark as touched
+ *
+ * @example
+ * // Mark field as touched on blur
+ * markFieldAsTouched('languages')} />
+ */
+export function markFieldAsTouched(fieldName: string) {
+ touchedFields.update((fields) => {
+ const newFields = new Set(fields);
+ newFields.add(fieldName);
+ return newFields;
+ });
+}
+
+let saveTimer: ReturnType | null = null;
+
+export function persistDraft() {
+ if (saveTimer) clearTimeout(saveTimer);
+
+ isSavingDraft.set(true);
+ saveTimer = setTimeout(() => {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ try {
+ const updatedDraft = {
+ ...draft,
+ updatedAt: new Date(),
+ };
+
+ currentDraft.set(updatedDraft);
+
+ draftRepo.update(draft.draftId, draft.userId, updatedDraft);
+
+ isSavingDraft.set(false);
+ persistenceError.set(null);
+ } catch (error) {
+ console.error("Failed to persist draft:", error);
+
+ // Handle QuotaExceededError specifically
+ if (error instanceof DOMException && error.name === "QuotaExceededError") {
+ persistenceError.set("storage_quota_exceeded");
+ } else {
+ persistenceError.set("storage_general_error");
+ }
+
+ // Keep isSavingDraft as true when persistence fails
+ isSavingDraft.set(true);
+ }
+ }, 1000);
+}
+
+/**
+ * Update Wizard Form data
+ *
+ * Merges new wizard updates with already existing wizard data.
+ *
+ * @param data - Partial object with wizard data that has been modified
+ */
+export function updateWizard(data: Partial, options: { markUnsaved?: boolean } = {}) {
+ const markUnsaved = options.markUnsaved ?? true;
+
+ currentDraft.update((draft) => {
+ if (!draft) return draft;
+
+ const updated = {
+ ...draft,
+ isDirty: markUnsaved ? true : draft.isDirty,
+ wizardForm: {
+ ...draft.wizardForm,
+
+ ...data,
+
+ configuration: {
+ ...draft.wizardForm.configuration,
+ ...data.configuration,
+ },
+
+ campaignInfo: {
+ ...draft.wizardForm.campaignInfo,
+ ...data.campaignInfo,
+ },
+
+ budgetItems: {
+ ...draft.wizardForm.budgetItems,
+ ...data.budgetItems,
+ },
+ },
+ };
+
+ return updated;
+ });
+
+ persistDraft();
+ if (markUnsaved) {
+ hasUnsavedChanges.set(true);
+ }
+}
+
+export function updateConfiguration(data: Partial) {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ updateWizard({
+ configuration: {
+ ...draft.wizardForm.configuration,
+ ...data,
+ },
+ });
+}
+
+export function updateCampaignInfo(data: Partial) {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ updateWizard({
+ campaignInfo: {
+ ...draft.wizardForm.campaignInfo,
+ ...data,
+ },
+ });
+}
+
+export function addReward(reward: ProjectReward) {
+ const errors = validateReward(reward);
+
+ if (Object.keys(errors).length > 0) {
+ return errors;
+ }
+
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ updateWizard({
+ rewards: [
+ ...draft.wizardForm.rewards,
+ {
+ ...reward,
+ project: reward.project,
+ },
+ ],
+ });
+
+ return {};
+}
+
+export function updateReward(index: number, reward: ProjectReward) {
+ const errors = validateReward(reward);
+
+ if (Object.keys(errors).length > 0) {
+ return errors;
+ }
+
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ const rewards = [...draft.wizardForm.rewards];
+
+ rewards[index] = {
+ ...rewards[index],
+ ...reward,
+ };
+
+ updateWizard({
+ rewards,
+ });
+
+ return {};
+}
+
+export function deleteReward(index: number) {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ updateWizard({
+ rewards: draft.wizardForm.rewards.filter((_, i) => i !== index),
+ });
+}
+
+export function addCollaboration(collab: ProjectCollaboration): Record {
+ const errors = validateCollaboration(collab);
+
+ if (Object.keys(errors).length > 0) {
+ return errors;
+ }
+
+ const draft = get(currentDraft);
+ if (!draft) return {};
+
+ updateWizard({
+ collaborations: [...draft.wizardForm.collaborations, { ...collab }],
+ });
+
+ return {};
+}
+
+export function updateCollaboration(
+ index: number,
+ collab: ProjectCollaboration,
+): Record {
+ const errors = validateCollaboration(collab);
+
+ if (Object.keys(errors).length > 0) {
+ return errors;
+ }
+
+ const draft = get(currentDraft);
+ if (!draft) return {};
+
+ const collaborations = [...draft.wizardForm.collaborations];
+
+ collaborations[index] = {
+ ...collaborations[index],
+ ...collab,
+ };
+
+ updateWizard({
+ collaborations,
+ });
+
+ return {};
+}
+
+export function deleteCollaboration(index: number) {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ updateWizard({
+ collaborations: draft.wizardForm.collaborations.filter((_, i) => i !== index),
+ });
+}
+
+export function addBudgetItem(item: ProjectBudgetItem) {
+ const errors = validateBudgetItem(item);
+
+ if (Object.keys(errors).length > 0) {
+ return errors;
+ }
+
+ const draft = get(currentDraft);
+ if (!draft) return {};
+
+ updateWizard({
+ budgetItems: {
+ ...draft.wizardForm.budgetItems,
+
+ [item.deadline]: [...draft.wizardForm.budgetItems[item.deadline], item],
+ },
+ });
+
+ return {};
+}
+
+export function updateBudgetItem(
+ index: number,
+ item: ProjectBudgetItem,
+ previousDeadline: "minimum" | "optimum" = item.deadline,
+) {
+ const errors = validateBudgetItem(item);
+
+ if (Object.keys(errors).length > 0) {
+ return errors;
+ }
+
+ const draft = get(currentDraft);
+ if (!draft) return {};
+
+ const budgetItems = { ...draft.wizardForm.budgetItems };
+ const previousItems = [...budgetItems[previousDeadline]];
+ const existing = previousItems[index];
+ const updatedItem = {
+ ...existing,
+ ...item,
+ };
+
+ if (previousDeadline === item.deadline) {
+ previousItems[index] = updatedItem;
+ budgetItems[item.deadline] = previousItems;
+ } else {
+ budgetItems[previousDeadline] = previousItems.filter((_, i) => i !== index);
+ budgetItems[item.deadline] = [...budgetItems[item.deadline], updatedItem];
+ }
+
+ updateWizard({
+ budgetItems,
+ });
+
+ return {};
+}
+
+export function deleteBudgetItem(index: number, deadline: "minimum" | "optimum") {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ updateWizard({
+ budgetItems: {
+ ...draft.wizardForm.budgetItems,
+
+ [deadline]: draft.wizardForm.budgetItems[deadline].filter((_, i) => i !== index),
+ },
+ });
+
+ hasUnsavedChanges.set(true);
+}
+
+/**
+ * Update create Project form data
+ *
+ * Merges new create project form updates with already existing project data.
+ *
+ * @param data - Partial object with Project API Type data that has been modified
+ */
+export function updateProject(data: Partial) {
+ currentDraft.update((draft) => {
+ if (!draft) return draft;
+
+ const updated = {
+ ...draft,
+ isDirty: true,
+ createProject: {
+ ...draft.createProject,
+ ...data,
+ },
+ };
+
+ return updated;
+ });
+
+ persistDraft();
+ hasUnsavedChanges.set(true);
+}
+
+export function markCurrentDraftClean(resources?: ProjectDraftResources) {
+ const draft = get(currentDraft);
+ if (!draft) return;
+
+ const nextResources = resources ?? {
+ rewards: draft.wizardForm.rewards,
+ collaborations: draft.wizardForm.collaborations,
+ budgetItems: draft.wizardForm.budgetItems,
+ };
+
+ const cleanDraft = {
+ ...draft,
+ isDirty: false,
+ apiSnapshot: nextResources,
+ wizardForm: {
+ ...draft.wizardForm,
+ rewards: nextResources.rewards,
+ collaborations: nextResources.collaborations,
+ budgetItems: nextResources.budgetItems,
+ },
+ updatedAt: new Date(),
+ };
+
+ currentDraft.set(cleanDraft);
+ draftRepo.update(cleanDraft.draftId, cleanDraft.userId, cleanDraft);
+ hasUnsavedChanges.set(false);
+}
+
+export function deleteCurrentDraft(draftId: string, userId: number) {
+ const current = get(currentDraft);
+
+ if (current?.draftId === draftId) {
+ currentDraft.set(null);
+ }
+
+ draftRepo.delete(draftId, userId);
+ touchedFields.set(new Set());
+ isSavingDraft.set(false);
+ persistenceError.set(null);
+}
+
+/**
+ * Navigate to a specific wizard step
+ *
+ * Navigation rules:
+ * - Can navigate to current step (no-op, returns true)
+ * - Can navigate backward to any previous step
+ * - Can navigate forward only if ALL previous steps are completed
+ * - Updates URL with browser history for back/forward button support
+ *
+ * @param targetStep - The step number to navigate to (1-indexed)
+ * @returns true if navigation was successful, false if blocked
+ *
+ * @example
+ * // Navigate to step 2 (allowed if step 1 is completed)
+ * navigateToStep(2);
+ *
+ * // Navigate back to step 1 (always allowed)
+ * navigateToStep(1);
+ */
+export function navigateToStep(targetStep: number): boolean {
+ const state = get(currentDraft);
+ const currentStep = state?.wizardForm.currentStep;
+
+ // No validation - free navigation
+ if (targetStep === currentStep) {
+ console.log(`[wizard-state] Already on step ${targetStep}`);
+ return true;
+ }
+
+ console.log(`[wizard-state] Navigating: ${currentStep} → ${targetStep}`);
+ updateWizard({ currentStep: targetStep });
+ updateUrl(targetStep);
+ return true;
+}
+
+/**
+ * Update browser URL with current step (for browser back/forward support)
+ * @param step - Current step number
+ */
+export function updateUrl(step: number) {
+ // SSR safety check
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ const url = new URL(window.location.href);
+ url.searchParams.set("step", step.toString());
+ window.history.pushState({ step }, "", url.toString());
+ } catch (error) {
+ console.error("[wizard-state] Failed to update URL:", error);
+ }
+}
diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts
index c5148919..237cf651 100644
--- a/src/stores/searchStore.ts
+++ b/src/stores/searchStore.ts
@@ -2,13 +2,9 @@ import { writable, derived, get } from "svelte/store";
import { projectsService } from "../services/projectsService";
-import type { Project } from "../openapi/client/types.gen";
+import type { ApiProjectsGetCollectionData, Project } from "../openapi/client/types.gen";
-export interface SearchFilters {
- query: string;
- statusFilter: string;
- categories: string[];
-}
+export type SearchFilters = ApiProjectsGetCollectionData["query"];
export interface SearchState {
filters: SearchFilters;
@@ -34,9 +30,10 @@ const isBrowser = typeof window !== "undefined";
function getInitialState(): SearchState {
return {
filters: {
- query: "",
- statusFilter: "",
- categories: [],
+ page: 1,
+ itemsPerPage: 20,
+ title: "",
+ status: "",
},
results: [],
isLoading: false,
@@ -61,23 +58,26 @@ function loadInitialState(): SearchState {
const state = getInitialState();
const urlParams = new URLSearchParams(window.location.search);
- // Initialize filters from URL parameters
- state.filters.query = urlParams.get("q") || "";
- state.filters.statusFilter = urlParams.get("status") || "";
+ // Initialize filters from URL parameters using API field names
+ if (state.filters) {
+ state.filters.title = urlParams.get("title") || "";
+ state.filters.status = urlParams.get("status") || "";
- const categories = urlParams.get("categories");
- if (categories) {
- state.filters.categories = categories.split(",").filter(Boolean);
+ // Handle categories[] array parameters
+ const categoriesArray = urlParams.getAll("categories[]");
+ if (categoriesArray.length > 0) {
+ state.filters["categories[]"] = categoriesArray;
+ }
+ // If we have URL parameters, mark as having searched
+ const hasUrlFilters =
+ state.filters.title ||
+ state.filters.status ||
+ (state.filters["categories[]"]?.length ?? 0) > 0;
+
+ if (hasUrlFilters) {
+ state.hasSearched = true;
+ }
}
-
- // If we have URL parameters, mark as having searched
- const hasUrlFilters =
- state.filters.query || state.filters.statusFilter || state.filters.categories.length > 0;
-
- if (hasUrlFilters) {
- state.hasSearched = true;
- }
-
return state;
}
@@ -221,9 +221,10 @@ function createSearchStore() {
update((state) => ({
...state,
filters: {
- query: "",
- statusFilter: "",
- categories: [],
+ page: 1,
+ itemsPerPage: 20,
+ title: "",
+ status: "",
},
})),
@@ -236,22 +237,26 @@ function createSearchStore() {
const state = get(searchStore);
const urlParams = new URLSearchParams();
+ const filters = state.filters ?? {};
- if (state.filters.query) {
- urlParams.set("q", state.filters.query);
- }
- if (state.filters.statusFilter) {
- urlParams.set("status", state.filters.statusFilter);
- }
- if (state.filters.categories.length > 0) {
- urlParams.set("categories", state.filters.categories.join(","));
- }
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value === undefined || value === null || value === "") return;
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => {
+ urlParams.append(key, String(item));
+ });
+ } else {
+ if (key !== "page" && key !== "itemsPerPage") {
+ urlParams.set(key, String(value));
+ }
+ }
+ });
const newUrl = urlParams.toString()
? `${window.location.pathname}?${urlParams.toString()}`
: window.location.pathname;
- // Use replaceState to avoid adding to browser history on every filter change
window.history.replaceState({}, "", newUrl);
},
@@ -276,7 +281,11 @@ function createSearchStore() {
hasPrevPage: pagination?.hasPrev || false,
hasSearched:
totalCount > 0 ||
- !!(filters.query || filters.statusFilter || filters.categories.length > 0),
+ !!(
+ filters?.title ||
+ filters?.status ||
+ (filters?.["categories[]"]?.length ?? 0) > 0
+ ),
isLoading: false,
hasError: false,
errorMessage: "",
@@ -469,7 +478,7 @@ export const resultCount = derived(searchStore, ($searchStore) => $searchStore.t
// Derived store to check if any filters are active
export const hasActiveFilters = derived(searchStore, ($searchStore) => {
const { filters } = $searchStore;
- return !!(filters.query || filters.statusFilter || filters.categories.length > 0);
+ return !!(filters?.title || filters?.status || (filters?.["categories[]"]?.length ?? 0) > 0);
});
// Pagination derived stores
diff --git a/src/stores/wizard-state.ts b/src/stores/wizard-state.ts
deleted file mode 100644
index 43e474e9..00000000
--- a/src/stores/wizard-state.ts
+++ /dev/null
@@ -1,1001 +0,0 @@
-/**
- * Wizard State Management Store
- *
- * Manages state for the multi-step project setup wizard.
- * Separate from project-draft.ts as they serve different purposes:
- * - project-draft.ts: Stage 1 (proposal submission)
- * - wizard-state.ts: Stage 2 (detailed setup after approval)
- *
- * Features:
- * - Step navigation with validation
- * - LocalStorage persistence (auto-save every 1 second)
- * - Pre-population from approved proposal
- * - Per-step validation tracking
- */
-
-import { z } from "astro/zod";
-import murmur from "murmurhash-js";
-import { writable, derived, get } from "svelte/store";
-
-import type { MoneyWithConversion, Project, ProjectBudgetItem } from "../openapi/client";
-
-/**
- * Wizard configuration data (Step 1: Configuration)
- */
-export interface WizardConfiguration {
- projectDeadline: "minimum" | "optimum"; // Default: minimum
-}
-
-/**
- * Media image data
- */
-export interface MediaImage {
- id: string;
- url: string; // Base64 data URL or API URL
- file?: File; // Original file reference
- size: number; // File size in bytes
- name: string; // Original filename
-}
-
-/**
- * Campaign Information data (Step 2)
- */
-export interface WizardCampaignInfo {
- // Media
- images: MediaImage[];
- video: string | undefined;
-
- // Rich text content (stored as HTML)
- objectives: string;
- legacy: string;
- targetAudience: string;
- team: string;
-
- // Validation tracking
- touched: Set;
- errors: Record;
-}
-
-/**
- * Wizard Rewards data (Step 3: Rewards)
- */
-export interface WizardReward {
- id?: string | number;
- title: string; // Title displayed in reward card
- description: string | null; // Description displayed in reward card
-
- // The minimal monetary sum to be able to claim this reward
- money: {
- amount: number;
- currency: string;
- };
-
- // isFinite sets if the reward has a limit of exchanges, and if not, unitsTotal sets the limit
- isFinite: boolean;
- unitsTotal: number | null;
-}
-
-/**
- * Wizard Collaborations data (Step 4: Collaborations)
- */
-export interface WizardCollaboration {
- title: string;
- description: string;
-}
-
-/**
- * Wizard Budget data (Step 5: Budget)
- */
-export interface WizardBudgetItems {
- minimum: ProjectBudgetItem[];
- optimum: ProjectBudgetItem[];
-}
-
-/**
- * Complete wizard state
- */
-export interface WizardState {
- // Project identification
- projectId?: string;
-
- // Pre-filled from proposal (Stage 1)
- title: string;
- subtitle: string;
- budget: MoneyWithConversion;
-
- // Step navigation
- currentStep: number;
-
- // Step 1: Configuration
- configuration: WizardConfiguration;
-
- // Step 2: Campaign Information
- campaignInfo: WizardCampaignInfo;
-
- // Step 3: Rewards
- rewards: WizardReward[];
-
- // Step 4: Collaborations
- collaborations: WizardCollaboration[];
-
- // Step 5: Budget
- budgetItems: WizardBudgetItems;
-
- // Pending future step (Phase 6) - placeholders
- // aboutYou: WizardAboutYou;
-}
-
-/**
- * Default wizard state
- */
-const getDefaultState = (): WizardState => ({
- projectId: undefined,
- title: "",
- subtitle: "",
- budget: {
- amount: 0,
- currency: "EUR",
- },
- currentStep: 1,
- configuration: {
- projectDeadline: "minimum", // Default to minimum deadline (1 round)
- },
- campaignInfo: {
- images: [],
- video: "",
- objectives: "",
- legacy: "",
- targetAudience: "",
- team: "",
- touched: new Set(),
- errors: {},
- },
- rewards: [
- {
- title: "",
- description: null,
- money: { amount: 0, currency: "EUR" },
- isFinite: false,
- unitsTotal: null,
- },
- ],
- collaborations: [],
- budgetItems: { minimum: [], optimum: [] },
-});
-
-/**
- * Main wizard state store
- */
-export const wizardState = writable(getDefaultState());
-
-/**
- * Validation errors for current step
- */
-export const validationErrors = writable>({});
-
-/**
- * Touched fields tracker
- */
-export const touchedFields = writable>(new Set());
-
-/**
- * Unsaved changes flag (for beforeunload warning)
- */
-export const hasUnsavedChanges = writable(false);
-
-/**
- * Persistence error state
- * Tracks localStorage save failures (quota exceeded, general errors)
- */
-export const persistenceError = writable(null);
-
-/**
- * Define whether the project is ready to publish (all steps completed and valid).
- * Used to enable/disable the Publish button in the UI
- */
-export const isReadyToPublish = writable(false);
-
-/**
- * LocalStorage key for wizard state persistence
- */
-const STORAGE_KEY = "goteo-project-wizard";
-
-/**
- * Save throttle timer
- */
-let saveTimer: ReturnType | null = null;
-
-/**
- * Initialize wizard state from an approved project
- *
- * Pre-populates wizard with data from the approved proposal (Stage 1).
- * Attempts to restore additional wizard-specific data from localStorage.
- *
- * @param project - The approved project data from the API
- *
- * @example
- * // Initialize wizard when user enters setup flow
- * const project = await getProject(projectId);
- * initializeFromProject(project);
- */
-export function initializeFromProject(project: Project) {
- wizardState.set({
- projectId: project.id ? String(project.id) : undefined,
- title: project.title || "",
- subtitle: project.subtitle || "",
- budget: {
- amount: project.budget?.minimum?.money?.amount || 0,
- currency: project.budget?.minimum?.money?.currency || "EUR",
- },
- currentStep: 1,
- configuration: {
- projectDeadline: "minimum",
- },
- campaignInfo: {
- images: [],
- video: "",
- objectives: "",
- legacy: "",
- targetAudience: "",
- team: "",
- touched: new Set(),
- errors: {},
- },
- rewards: [],
- collaborations: [],
- budgetItems: { minimum: [], optimum: [] },
- });
-
- // Try to restore additional wizard data from localStorage
- restoreFromLocalStorage();
-}
-
-/**
- * Save wizard state to localStorage (throttled)
- *
- * Automatically throttled to max 1 save per second to prevent excessive writes.
- * Handles quota exceeded errors and general storage failures gracefully.
- * Updates persistenceError store on failure, keeps hasUnsavedChanges true on error.
- *
- * @example
- * // Called automatically by updateConfiguration, completeCurrentStep, etc.
- * // Can also be called manually if needed
- * saveToLocalStorage();
- */
-export function saveToLocalStorage() {
- // Clear existing timer
- if (saveTimer) {
- clearTimeout(saveTimer);
- }
-
- // Set new timer (throttle to max 1 save per second)
- saveTimer = setTimeout(() => {
- const state = get(wizardState);
-
- // Convert Set to Array for JSON serialization
- const serializable = {
- ...state,
- campaignInfo: {
- ...state.campaignInfo,
- touched: Array.from(state.campaignInfo.touched),
- },
- };
-
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
- hasUnsavedChanges.set(false);
- persistenceError.set(null);
- } catch (error) {
- console.error("Failed to save wizard state to localStorage:", error);
-
- // Handle QuotaExceededError specifically
- if (error instanceof DOMException && error.name === "QuotaExceededError") {
- persistenceError.set("storage_quota_exceeded");
- } else {
- persistenceError.set("storage_general_error");
- }
-
- // Keep hasUnsavedChanges as true when persistence fails
- hasUnsavedChanges.set(true);
- }
- }, 1000);
-}
-
-/**
- * Restore wizard state from localStorage
- *
- * Only restores wizard-specific fields (currentStep, completedSteps, configuration, campaignInfo).
- * Does not overwrite pre-filled proposal data (title, subtitle, etc.).
- *
- * @returns true if restoration was successful, false otherwise
- *
- * @example
- * // Restore wizard progress after page reload
- * if (restoreFromLocalStorage()) {
- * console.log('Wizard state restored');
- * }
- */
-export function restoreFromLocalStorage(): boolean {
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (!stored) {
- return false;
- }
-
- const parsed = JSON.parse(stored);
-
- // Convert Arrays back to Sets
- if (parsed.campaignInfo?.touched) {
- parsed.campaignInfo.touched = new Set(parsed.campaignInfo.touched);
- }
-
- // Only restore wizard-specific fields (not pre-filled proposal data)
- wizardState.update((state) => ({
- ...state,
- currentStep: parsed.currentStep || 1,
- configuration: parsed.configuration || getDefaultState().configuration,
- campaignInfo: parsed.campaignInfo || getDefaultState().campaignInfo,
- rewards: parsed.rewards || getDefaultState().rewards,
- collaborations: parsed.collaborations || getDefaultState().collaborations,
- budgetItems: parsed.budgetItems || getDefaultState().budgetItems,
- }));
-
- return true;
- } catch (error) {
- console.error("Failed to restore wizard state from localStorage:", error);
- return false;
- }
-}
-
-/**
- * Clear wizard state from localStorage
- *
- * Should be called after successful project publish to clean up stored data.
- *
- * @example
- * // After successful project submission
- * await publishProject(projectData);
- * clearLocalStorage();
- */
-export function clearLocalStorage() {
- try {
- localStorage.removeItem(STORAGE_KEY);
- hasUnsavedChanges.set(false);
- persistenceError.set(null);
- } catch (error) {
- console.error("Failed to clear wizard state from localStorage:", error);
- }
-}
-
-/**
- * Navigate to a specific wizard step
- *
- * Navigation rules:
- * - Can navigate to current step (no-op, returns true)
- * - Can navigate backward to any previous step
- * - Can navigate forward only if ALL previous steps are completed
- * - Updates URL with browser history for back/forward button support
- *
- * @param targetStep - The step number to navigate to (1-indexed)
- * @returns true if navigation was successful, false if blocked
- *
- * @example
- * // Navigate to step 2 (allowed if step 1 is completed)
- * navigateToStep(2);
- *
- * // Navigate back to step 1 (always allowed)
- * navigateToStep(1);
- */
-export function navigateToStep(targetStep: number): boolean {
- const state = get(wizardState);
- const currentStep = state.currentStep;
-
- // No validation - free navigation
- if (targetStep === currentStep) {
- console.log(`[wizard-state] Already on step ${targetStep}`);
- return true;
- }
-
- console.log(`[wizard-state] Navigating: ${currentStep} → ${targetStep}`);
- wizardState.update((s) => ({ ...s, currentStep: targetStep }));
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
- updateUrl(targetStep);
- return true;
-}
-
-/**
- * Update browser URL with current step (for browser back/forward support)
- * @param step - Current step number
- */
-function updateUrl(step: number) {
- // SSR safety check
- if (typeof window === "undefined") {
- return;
- }
-
- try {
- const url = new URL(window.location.href);
- url.searchParams.set("step", step.toString());
- window.history.pushState({ step }, "", url.toString());
- } catch (error) {
- console.error("[wizard-state] Failed to update URL:", error);
- }
-}
-
-/**
- * Mark a field as touched (for validation UX)
- *
- * Used to track which fields the user has interacted with,
- * so validation errors only show after user touches the field.
- *
- * @param fieldName - The field identifier to mark as touched
- *
- * @example
- * // Mark field as touched on blur
- * markFieldAsTouched('languages')} />
- */
-export function markFieldAsTouched(fieldName: string) {
- touchedFields.update((fields) => {
- const newFields = new Set(fields);
- newFields.add(fieldName);
- return newFields;
- });
-}
-
-/**
- * Reset wizard to initial state
- *
- * Clears all wizard data including localStorage.
- * Should be used when user cancels or starts a new wizard session.
- *
- * @example
- * // Reset wizard on cancel
- * function handleCancel() {
- * if (confirm('Discard all changes?')) {
- * resetWizard();
- * navigate('/dashboard');
- * }
- * }
- */
-export function resetWizard() {
- wizardState.set(getDefaultState());
- validationErrors.set({});
- touchedFields.set(new Set());
- hasUnsavedChanges.set(false);
- persistenceError.set(null);
- clearLocalStorage();
-}
-
-/**
- * Validation schema for Configuration step
- */
-export const configurationSchema = z.object({
- projectDeadline: z.union([z.literal(1), z.literal(2)]),
-});
-
-/**
- * Update configuration data (Step 1)
- *
- * Merges partial configuration updates with existing data.
- * Triggers auto-save to localStorage.
- *
- * @param config - Partial configuration object to merge
- *
- * @example
- * // Update languages
- * updateConfiguration({ languages: ['es', 'en'] });
- */
-export function updateConfiguration(config: Partial) {
- wizardState.update((state) => ({
- ...state,
- configuration: {
- ...state.configuration,
- ...config,
- },
- }));
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-/**
- * Validate Configuration step (Step 1)
- *
- * Validates against the configurationSchema using Zod.
- * Updates validationErrors store with any validation failures.
- *
- * @returns true if configuration is valid, false otherwise
- *
- * @example
- * // Validate before completing step
- * function handleContinue() {
- * if (validateConfiguration()) {
- * completeCurrentStep();
- * }
- * }
- */
-export function validateConfiguration(): boolean {
- const state = get(wizardState);
- const result = configurationSchema.safeParse(state.configuration);
-
- if (!result.success) {
- const errors: Record = {};
- result.error.issues.forEach((issue) => {
- const fieldName = issue.path[0] as string;
- if (!errors[fieldName]) {
- errors[fieldName] = issue.message;
- }
- });
- validationErrors.set(errors);
- return false;
- }
-
- validationErrors.set({});
- return true;
-}
-
-/**
- * Derived store: is Configuration step valid?
- */
-export const isConfigurationValid = derived(
- [wizardState, validationErrors],
- ([$state, $errors]) => {
- // Check for validation errors
- if (Object.keys($errors).length > 0) {
- return false;
- }
-
- // Check required fields
- const config = $state.configuration;
- const deadlineSelected = config.projectDeadline;
-
- return deadlineSelected;
- },
-);
-
-// ============================================
-// Campaign Info State Management (Step 2)
-// ============================================
-
-/**
- * Update campaign info data (Step 2)
- *
- * Merges partial campaign info updates with existing data.
- * Triggers auto-save to localStorage.
- *
- * @param info - Partial campaign info object to merge
- *
- * @example
- * // Update objectives
- * updateCampaignInfo({ objectives: 'My objectives...
' );
- *
- * // Add an image
- * updateCampaignInfo({
- * images: [...currentImages, newImage]
- * );
- */
-export function updateCampaignInfo(info: Partial) {
- wizardState.update((state) => ({
- ...state,
- campaignInfo: {
- ...state.campaignInfo,
- ...info,
- },
- }));
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-/**
- * Strip HTML tags from a string to get plain text
- * Used for validating rich text field lengths
- *
- * @param html - HTML string to strip
- * @returns Plain text without HTML tags
- */
-export function stripHtml(html: string): string {
- if (typeof document === "undefined") {
- // SSR fallback: simple regex-based stripping
- return html.replace(/<[^>]*>/g, "").trim();
- }
- const div = document.createElement("div");
- div.innerHTML = html;
- return div.textContent || div.innerText || "";
-}
-
-/**
- * Validate Campaign Info step (Step 2)
- *
- * Validates all campaign info fields according to requirements:
- * - At least 1 image OR 1 video required
- * - Objectives: min 50, max 5000 characters (plain text)
- * - Legacy: min 50, max 5000 characters
- * - Target audience: min 30, max 5000 characters
- * - Team: min 50, max 5000 characters
- *
- * @returns Record of field errors (empty if valid)
- *
- * @example
- * // Validate before completing step
- * function handleContinue() {
- * const errors = validateCampaignInfo();
- * if (Object.keys(errors).length === 0) {
- * completeCurrentStep();
- * }
- * }
- */
-export function validateCampaignInfo(): Record {
- const state = get(wizardState);
- const data = state.campaignInfo;
- const errors: Record = {};
-
- // Media validation
- if (data.images.length === 0 && !data.video) {
- errors.media = "pages.project.edit.rewards.validationn_info.reward.media.required";
- }
-
- // Objectives validation
- const objectivesPlainText = stripHtml(data.objectives).trim();
- if (objectivesPlainText.length === 0) {
- errors.objectives =
- "pages.project.edit.rewards.validationn_info.reward.objectives.required";
- } else if (objectivesPlainText.length < 50) {
- errors.objectives =
- "pages.project.edit.rewards.validationn_info.reward.objectives.min_length";
- } else if (objectivesPlainText.length > 5000) {
- errors.objectives =
- "pages.project.edit.rewards.validationn_info.reward.objectives.max_length";
- }
-
- // Legacy validation
- const legacyPlainText = stripHtml(data.legacy).trim();
- if (legacyPlainText.length === 0) {
- errors.legacy = "pages.project.edit.rewards.validationn_info.reward.legacy.required";
- } else if (legacyPlainText.length < 50) {
- errors.legacy = "pages.project.edit.rewards.validationn_info.reward.legacy.min_length";
- } else if (legacyPlainText.length > 5000) {
- errors.legacy = "pages.project.edit.rewards.validationn_info.reward.legacy.max_length";
- }
-
- // Target audience validation
- const targetPlainText = stripHtml(data.targetAudience).trim();
- if (targetPlainText.length === 0) {
- errors.targetAudience =
- "pages.project.edit.rewards.validationn_info.reward.target.required";
- } else if (targetPlainText.length < 30) {
- errors.targetAudience =
- "pages.project.edit.rewards.validationn_info.reward.target.min_length";
- } else if (targetPlainText.length > 5000) {
- errors.targetAudience =
- "pages.project.edit.rewards.validationn_info.reward.target.max_length";
- }
-
- // Team validation
- const teamPlainText = stripHtml(data.team).trim();
- if (teamPlainText.length === 0) {
- errors.team = "pages.project.edit.rewards.validationn_info.reward.team.required";
- } else if (teamPlainText.length < 50) {
- errors.team = "pages.project.edit.rewards.validationn_info.reward.team.min_length";
- } else if (teamPlainText.length > 5000) {
- errors.team = "pages.project.edit.rewards.validationn_info.reward.team.max_length";
- }
-
- return errors;
-}
-
-/**
- * Check if Campaign Info step is valid
- *
- * @returns true if campaign info is valid, false otherwise
- */
-export function isCampaignInfoValid(): boolean {
- const errors = validateCampaignInfo();
- return Object.keys(errors).length === 0;
-}
-
-/**
- * Derived store: is Campaign Info step valid?
- */
-export const isCampaignInfoValidStore = derived(wizardState, () => {
- const errors = validateCampaignInfo();
- return Object.keys(errors).length === 0;
-});
-
-// ============================================
-// Rewards State Management (Step 3)
-// ============================================
-
-/**
- * Update rewards data (Step 3)
- *
- * Merges rewards updates with existing data.
- * Triggers auto-save to localStorage.
- *
- * @param index - Index of current reward
- * @param reward - Reward object to merge
- *
- * @example
- * // Update reward
- * updateReward(index: currentIndex, reward: updatedReward);
- */
-export function updateReward(index: number, reward: WizardReward) {
- const errors = validateReward(reward);
-
- if (Object.keys(errors).length > 0) {
- return errors;
- }
-
- wizardState.update((state) => {
- const updated = [...state.rewards];
- updated[index] = reward;
- return { ...state, rewards: updated };
- });
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function addReward(reward: WizardReward) {
- const errors = validateReward(reward);
-
- if (Object.keys(errors).length > 0) {
- return errors;
- }
-
- const id = murmur.murmur3(JSON.stringify(reward) + Date.now());
-
- wizardState.update((state) => ({
- ...state,
- rewards: [...state.rewards, { ...reward, id }],
- }));
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function deleteReward(index: number) {
- wizardState.update((state) => ({
- ...state,
- rewards: state.rewards.filter((_, i) => i !== index),
- }));
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function validateReward(reward: WizardReward): Record {
- const errors: Record = {};
- const hash = murmur.murmur3(JSON.stringify(reward));
-
- if (!reward.title.trim()) {
- errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title";
- }
-
- if (!reward.money.amount || reward.money.amount <= 0) {
- errors[`reward_error_amount_${hash}`] = "pages.project.edit.rewards.validation.amount";
- }
-
- if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) {
- errors[`reward_error_units_${hash}`] = "pages.project.edit.rewards.validation.units";
- }
-
- return errors;
-}
-
-// ============================================
-// Collaborations State Management (Step 4)
-// ============================================
-
-/**
- * Update Collaborations data (Step 4)
- *
- * Merges collaborations updates with existing data.
- * Triggers auto-save to localStorage.
- *
- * @param index - Current collaboration's index
- * @param collab - Collaboration object to merge
- *
- * @example
- * // Update collaboration
- * updateCollaboration(index: currentIndex, collab: updatedCollab);
- */
-export function updateCollaboration(
- index: number,
- collab: WizardCollaboration,
-): Record {
- const errors = validateCollaboration(collab);
-
- if (Object.keys(errors).length > 0) {
- return errors;
- }
-
- wizardState.update((state) => {
- const updated = [...state.collaborations];
- updated[index] = collab;
-
- return { ...state, collaborations: updated };
- });
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-
- return {};
-}
-
-export function addCollaboration(collab: WizardCollaboration): Record {
- const errors = validateCollaboration(collab);
-
- if (Object.keys(errors).length > 0) {
- return errors;
- }
-
- wizardState.update((state) => ({
- ...state,
- collaborations: [...state.collaborations, collab],
- }));
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-
- return {};
-}
-
-export function deleteCollaboration(index: number) {
- wizardState.update((state) => {
- const collaborations = state.collaborations.splice(index, 1); // Remove the collaboration from the array
-
- return {
- ...state,
- collaborations: { ...collaborations },
- };
- });
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function validateCollaboration(collab: WizardCollaboration): Record {
- const errors: Record = {};
- const hash = murmur.murmur3(JSON.stringify(collab));
-
- if (!collab.title.trim()) {
- errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title";
- }
-
- if (collab.description && collab.description.length > 1000) {
- errors[`collab_error_description_too_long_${hash}`] =
- "pages.project.edit.collaborations.validation.descriptionTooLong";
- }
-
- if (!collab.description.trim()) {
- errors[`collab_error_description_${hash}`] =
- "pages.project.edit.collaborations.validation.description";
- }
-
- return errors;
-}
-
-// ============================================
-// BudgetItems State Management (Step 5)
-// ============================================
-
-/**
- * Update budgetItems data (Step 5)
- *
- * Merges budget items updates with existing data.
- * Triggers auto-save to localStorage.
- *
- * @param index - Current budget item index
- * @param item - Budget item object to merge
- *
- * @example
- * // Update unitsTotal
- * updateRewards({ unitsTotal: newCount });
- */
-export function updateBudgetItem(index: number, item: ProjectBudgetItem) {
- const errors = validateBudgetItem(item);
-
- if (Object.keys(errors).length > 0) {
- return errors;
- }
-
- wizardState.update((state) => {
- const updated = [...state.budgetItems[item.deadline]];
- updated[index] = item;
-
- return {
- ...state,
- budgetItems: {
- ...state.budgetItems,
- [item.deadline]: updated,
- },
- };
- });
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function addBudgetItem(item: ProjectBudgetItem) {
- const errors = validateBudgetItem(item);
-
- if (Object.keys(errors).length > 0) {
- return errors;
- }
-
- wizardState.update((state) => ({
- ...state,
- budgetItems: {
- ...state.budgetItems,
- [item.deadline]: [...state.budgetItems[item.deadline], item],
- },
- }));
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function deleteBudgetItem(index: number, deadline: "minimum" | "optimum") {
- wizardState.update((state) => ({
- ...state,
- budgetItems: {
- ...state.budgetItems,
- [deadline]: state.budgetItems[deadline].filter((_, i) => i !== index),
- },
- }));
-
- hasUnsavedChanges.set(true);
- saveToLocalStorage();
-}
-
-export function validateBudgetItem(item: ProjectBudgetItem): Record {
- const errors: Record = {};
- const hash = murmur.murmur3(JSON.stringify(item));
-
- if (!item.title.trim()) {
- errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title.";
- }
-
- if (!item.description.trim()) {
- errors[`budget_error_description_${hash}`] =
- "pages.project.edit.budget.validation.description";
- }
-
- if (!item.money.amount || item.money.amount <= 0) {
- errors[`budget_error_amount_${hash}`] = "pages.project.edit.budget.validation.amount";
- }
-
- if (!item.money.currency) {
- errors[`budget_error_currency_${hash}`] = "pages.project.edit.budget.validation.currency";
- }
-
- if (!item.type) {
- errors[`budget_error_type_${hash}`] = "pages.project.edit.budget.validation.type";
- }
-
- if (!item.deadline || (item.deadline !== "minimum" && item.deadline !== "optimum")) {
- errors[`budget_error_deadline_${hash}`] = "pages.project.edit.budget.validation.class";
- }
-
- return errors;
-}
-
-export function validateBudgetAmount() {
- const { budgetItems, budget } = get(wizardState);
- const errors: Record = {};
-
- if (budgetItems.minimum.length <= 0) {
- errors.minimum_length = "pages.project.edit.budget.validation.minimumItemsLength";
- }
-
- let minimumItemsTotalAmount: number = 0;
-
- for (let i = 0; i < budgetItems.minimum.length - 1; i++) {
- minimumItemsTotalAmount += budgetItems.minimum[i].money.amount;
- }
-
- if (minimumItemsTotalAmount !== budget.amount) {
- errors.minimum_length = "pages.project.edit.budget.validation.amountMinimum";
- }
-
- return errors;
-}
diff --git a/src/types/campaign.ts b/src/types/campaign.ts
index 7503e311..6d15f3e7 100644
--- a/src/types/campaign.ts
+++ b/src/types/campaign.ts
@@ -30,8 +30,8 @@ export interface Campaign extends Project {
title: string;
/** Campaign image URL path */
image: string;
- /** Current funding amount */
- obtained: Money;
+ /** Current funding amount — optional when fetched client-side per card */
+ obtained?: Money;
/** Minimum funding goal */
minimum: Money;
/** Optional optimum funding goal */
diff --git a/src/utils/cachedFetch.ts b/src/utils/cachedFetch.ts
deleted file mode 100644
index 94b39c53..00000000
--- a/src/utils/cachedFetch.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { getBaseUrl } from "./consts";
-import { extractId } from "./extractId";
-import { client } from "../openapi/client/client.gen";
-import {
- apiAccountingsIdGetUrl,
- apiGatewayCheckoutsIdGetUrl,
- apiProjectsIdOrSlugGetUrl,
- apiTipjarsIdGetUrl,
- apiUsersIdOrHandleGetUrl,
-} from "../openapi/client/paths.gen";
-
-import type { Accounting, GatewayCheckout, Project, Tipjar, User } from "../openapi/client";
-
-export async function fetchWithPersistentCache(
- iri: string,
- token: string,
- API_CACHE_NAME: string,
-): Promise {
- const cache = await caches.open(API_CACHE_NAME);
-
- const cached = await cache.match(iri);
- if (cached) return cached.json();
-
- const baseUrl = getBaseUrl();
- const fullUrl = joinUrl(baseUrl, iri);
-
- const response = await fetch(fullUrl, {
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: "application/ld+json",
- },
- });
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
-
- await cache.put(fullUrl, response.clone());
- return response.json();
-}
-
-export async function fetchAccounting(iri: string | null, token: string, API_CACHE_NAME: string) {
- if (!iri) return;
-
- const url = client.buildUrl({ url: apiAccountingsIdGetUrl, path: { id: extractId(iri) } });
- return fetchWithPersistentCache(url, token, API_CACHE_NAME);
-}
-
-export async function fetchUser(iri: string | null, token: string, API_CACHE_NAME: string) {
- if (!iri) return;
-
- const url = client.buildUrl({ url: apiUsersIdOrHandleGetUrl, path: { id: extractId(iri) } });
- return fetchWithPersistentCache(url, token, API_CACHE_NAME);
-}
-
-export async function fetchProject(iri: string | null, token: string, API_CACHE_NAME: string) {
- if (!iri) return;
-
- const url = client.buildUrl({
- url: apiProjectsIdOrSlugGetUrl,
- path: { idOrSlug: extractId(iri) },
- });
- return fetchWithPersistentCache(url, token, API_CACHE_NAME);
-}
-
-export async function fetchCheckout(iri: string | null, token: string, API_CACHE_NAME: string) {
- if (!iri) return;
-
- const url = client.buildUrl({
- url: apiGatewayCheckoutsIdGetUrl,
- path: { id: extractId(iri) },
- });
- return fetchWithPersistentCache(url, token, API_CACHE_NAME);
-}
-
-export async function fetchTipjar(iri: string | null, token: string, API_CACHE_NAME: string) {
- if (!iri) return;
-
- const url = client.buildUrl({
- url: apiTipjarsIdGetUrl,
- path: { id: extractId(iri) },
- });
- return fetchWithPersistentCache(url, token, API_CACHE_NAME);
-}
-
-function joinUrl(base: string, path: string) {
- return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
-}
diff --git a/src/utils/drafts/db.ts b/src/utils/drafts/db.ts
new file mode 100644
index 00000000..d57d2643
--- /dev/null
+++ b/src/utils/drafts/db.ts
@@ -0,0 +1,11 @@
+import { Dexie, type EntityTable } from "dexie";
+
+import type { Draft } from "../../stores/drafts/projectDraft";
+
+export const db = new Dexie("goteo-project-drafts") as Dexie & {
+ drafts: EntityTable;
+};
+
+db.version(2).stores({
+ drafts: "draftId, userId, [userId+updatedAt]",
+});
diff --git a/src/utils/drafts/repository.ts b/src/utils/drafts/repository.ts
new file mode 100644
index 00000000..49c93f68
--- /dev/null
+++ b/src/utils/drafts/repository.ts
@@ -0,0 +1,48 @@
+import Dexie from "dexie";
+
+import { db } from "./db";
+
+import type { Draft } from "../../stores/drafts/projectDraft";
+
+export const draftRepo = {
+ async create(draft: Draft) {
+ await db.drafts.put(draft);
+ return draft;
+ },
+
+ async update(draftId: string, userId: number, data: Partial) {
+ const existing = await db.drafts.get(draftId);
+
+ if (!existing || existing.userId !== userId) return;
+
+ await db.drafts.put({
+ ...existing,
+ ...data,
+ updatedAt: new Date(),
+ });
+ },
+
+ async get(draftId: string) {
+ const draft = await db.drafts.get(draftId);
+
+ if (!draft) return undefined;
+
+ return draft;
+ },
+
+ async getAllByUser(userId: number) {
+ return db.drafts
+ .where("[userId+updatedAt]")
+ .between([userId, Dexie.minKey], [userId, Dexie.maxKey])
+ .reverse()
+ .toArray();
+ },
+
+ async delete(draftId: string, userId: number) {
+ const draft = await db.drafts.get(draftId);
+
+ if (!draft || draft.userId !== userId) return;
+
+ await db.drafts.delete(draftId);
+ },
+};
diff --git a/src/utils/hydra.ts b/src/utils/hydra.ts
index a660fc09..65288cf9 100644
--- a/src/utils/hydra.ts
+++ b/src/utils/hydra.ts
@@ -4,11 +4,17 @@ export function toCollectionItems(collection: unknown): T[] {
}
if (collection && typeof collection === "object") {
- const hydraMembers = (collection as Record)["hydra:member"];
+ const record = collection as Record;
+ const hydraMembers = record["hydra:member"];
+ const members = record.member;
if (Array.isArray(hydraMembers)) {
return hydraMembers as T[];
}
+
+ if (Array.isArray(members)) {
+ return members as T[];
+ }
}
return [];
diff --git a/src/utils/objectStorage.ts b/src/utils/objectStorage.ts
index 17d55cc3..f0f8cfe4 100644
--- a/src/utils/objectStorage.ts
+++ b/src/utils/objectStorage.ts
@@ -19,7 +19,19 @@ export const STORAGE_MAXSIZE = 8388608;
/**
* Allowed MIME types of single objects.
*/
-export const STORAGE_ALLOWEDTYPES = ["image/jpeg", "image/webp", "image/png", "image/gif"];
+export const STORAGE_ALLOWEDTYPES = [
+ "image/jpeg",
+ "image/webp",
+ "image/png",
+ "image/gif",
+ "application/pdf",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "video/mp4",
+ "video/webm",
+];
export const client = new S3Client({
region: import.meta.env.OBJECT_STORAGE_REGION,
diff --git a/src/utils/projectPublisher.ts b/src/utils/projectPublisher.ts
new file mode 100644
index 00000000..b45ea634
--- /dev/null
+++ b/src/utils/projectPublisher.ts
@@ -0,0 +1,182 @@
+import {
+ createBudgetItem,
+ createCollaboration,
+ createReward,
+ deleteBudgetItem,
+ deleteCollaboration,
+ deleteReward,
+ patchProject,
+ updateBudgetItem,
+ updateCollaboration,
+ updateReward,
+} from "./projectSubmissionApi";
+import { validateDraftToPublish } from "../stores/drafts/draftValidation";
+
+import type { Session } from "../auth/types";
+import type { ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../openapi/client";
+import type { Draft, ProjectDraftResources } from "../stores/drafts/projectDraft";
+
+export class PublishValidationError extends Error {
+ constructor(public errors: Record) {
+ super("Draft validation failed");
+ }
+}
+
+function getIds(items: T[]) {
+ return new Set(items.flatMap((item) => (item.id ? [item.id] : [])));
+}
+
+function flattenBudgetItems(resources: ProjectDraftResources["budgetItems"]) {
+ return [...resources.minimum, ...resources.optimum];
+}
+
+async function assertResult(result: { data?: T; error?: unknown }) {
+ if (result.error) throw result.error;
+ return result.data;
+}
+
+async function syncRewards(current: ProjectReward[], snapshot: ProjectReward[], session: Session) {
+ const currentIds = getIds(current);
+ const deleted = snapshot.filter((reward) => reward.id && !currentIds.has(reward.id));
+
+ await Promise.all(
+ deleted.map(async (reward) => {
+ const result = await deleteReward(reward.id!, session);
+ await assertResult(result);
+ }),
+ );
+
+ const synced = await Promise.all(
+ current.map(async (reward) => {
+ const result = reward.id
+ ? await updateReward(reward, session)
+ : await createReward(reward, session);
+
+ return assertResult(result);
+ }),
+ );
+
+ return synced.filter(Boolean) as ProjectReward[];
+}
+
+async function syncCollaborations(
+ current: ProjectCollaboration[],
+ snapshot: ProjectCollaboration[],
+ session: Session,
+) {
+ const currentIds = getIds(current);
+ const deleted = snapshot.filter((collab) => collab.id && !currentIds.has(collab.id));
+
+ await Promise.all(
+ deleted.map(async (collab) => {
+ const result = await deleteCollaboration(collab.id!, session);
+ await assertResult(result);
+ }),
+ );
+
+ const synced = await Promise.all(
+ current.map(async (collab) => {
+ const result = collab.id
+ ? await updateCollaboration(collab, session)
+ : await createCollaboration(collab, session);
+
+ return assertResult(result);
+ }),
+ );
+
+ return synced.filter(Boolean) as ProjectCollaboration[];
+}
+
+async function syncBudgetItems(
+ current: ProjectDraftResources["budgetItems"],
+ snapshot: ProjectDraftResources["budgetItems"],
+ session: Session,
+) {
+ const currentItems = flattenBudgetItems(current);
+ const snapshotItems = flattenBudgetItems(snapshot);
+ const currentIds = getIds(currentItems);
+ const deleted = snapshotItems.filter((item) => item.id && !currentIds.has(item.id));
+
+ await Promise.all(
+ deleted.map(async (item) => {
+ const result = await deleteBudgetItem(item.id!, session);
+ await assertResult(result);
+ }),
+ );
+
+ const synced = await Promise.all(
+ currentItems.map(async (item) => {
+ const result = item.id
+ ? await updateBudgetItem(item, session)
+ : await createBudgetItem(item, session);
+
+ return assertResult(result);
+ }),
+ );
+
+ const syncedItems = synced.filter(Boolean) as ProjectBudgetItem[];
+
+ return {
+ minimum: syncedItems.filter((item) => item.deadline === "minimum"),
+ optimum: syncedItems.filter((item) => item.deadline === "optimum"),
+ };
+}
+
+export async function publishDraft(draft: Draft, session: Session, projectId: string) {
+ const validationErrors = validateDraftToPublish(draft);
+
+ if (Object.keys(validationErrors).length > 0) {
+ throw new PublishValidationError(validationErrors);
+ }
+
+ const wizard = draft.wizardForm;
+ const apiSnapshot = draft.apiSnapshot ?? {
+ rewards: [],
+ collaborations: [],
+ budgetItems: {
+ minimum: [],
+ optimum: [],
+ },
+ };
+
+ const description = [
+ wizard.campaignInfo.objectives,
+ wizard.campaignInfo.legacy,
+ wizard.campaignInfo.targetAudience,
+ wizard.campaignInfo.team,
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+
+ const result = await patchProject(
+ projectId,
+ {
+ title: draft.createProject.title,
+ subtitle: draft.createProject.subtitle,
+ categories: draft.createProject.categories,
+ video: wizard.campaignInfo.video,
+ description,
+ deadline: wizard.configuration.projectDeadline,
+ },
+ session,
+ );
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ const [rewards, collaborations, budgetItems] = await Promise.all([
+ syncRewards(wizard.rewards, apiSnapshot.rewards, session),
+ syncCollaborations(wizard.collaborations, apiSnapshot.collaborations, session),
+ syncBudgetItems(wizard.budgetItems, apiSnapshot.budgetItems, session),
+ ]);
+
+ return {
+ project: result.data,
+ resources: {
+ rewards,
+ collaborations,
+ budgetItems,
+ },
+ };
+}
diff --git a/src/utils/projectSubmissionApi.ts b/src/utils/projectSubmissionApi.ts
new file mode 100644
index 00000000..57efeb88
--- /dev/null
+++ b/src/utils/projectSubmissionApi.ts
@@ -0,0 +1,152 @@
+import {
+ apiProjectBudgetItemsGetCollection,
+ apiProjectBudgetItemsIdDelete,
+ apiProjectBudgetItemsIdPatch,
+ apiProjectBudgetItemsPost,
+ apiProjectCollaborationsGetCollection,
+ apiProjectCollaborationsIdDelete,
+ apiProjectCollaborationsIdPatch,
+ apiProjectCollaborationsPost,
+ apiProjectRewardsGetCollection,
+ apiProjectRewardsIdDelete,
+ apiProjectRewardsIdPatch,
+ apiProjectRewardsPost,
+ apiProjectsIdPatch,
+} from "../openapi/client";
+
+import type { Session } from "../auth/types";
+import type {
+ ProjectReward,
+ ProjectCollaboration,
+ ProjectBudgetItem,
+ ProjectProjectUpdationDto,
+} from "../openapi/client";
+import type { ProjectDraftResources } from "../stores/drafts/projectDraft";
+
+export async function patchProject(
+ projectId: string,
+ body: ProjectProjectUpdationDto,
+ session: Session,
+) {
+ return apiProjectsIdPatch({
+ path: { id: projectId },
+ body,
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function createReward(reward: ProjectReward, session: Session) {
+ return apiProjectRewardsPost({
+ body: reward,
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function updateReward(reward: ProjectReward, session: Session) {
+ if (!reward.id) {
+ throw new Error("Cannot update reward without id");
+ }
+
+ return apiProjectRewardsIdPatch({
+ path: { id: String(reward.id) },
+ body: reward,
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function deleteReward(rewardId: number, session: Session) {
+ return apiProjectRewardsIdDelete({
+ path: { id: String(rewardId) },
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function createCollaboration(collab: ProjectCollaboration, session: Session) {
+ return apiProjectCollaborationsPost({
+ body: {
+ ...collab,
+ isFulfilled: false,
+ },
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function updateCollaboration(collab: ProjectCollaboration, session: Session) {
+ if (!collab.id) {
+ throw new Error("Cannot update collaboration without id");
+ }
+
+ return apiProjectCollaborationsIdPatch({
+ path: { id: String(collab.id) },
+ body: collab,
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function deleteCollaboration(collabId: number, session: Session) {
+ return apiProjectCollaborationsIdDelete({
+ path: { id: String(collabId) },
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function createBudgetItem(item: ProjectBudgetItem, session: Session) {
+ return apiProjectBudgetItemsPost({
+ body: item,
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function updateBudgetItem(item: ProjectBudgetItem, session: Session) {
+ if (!item.id) {
+ throw new Error("Cannot update budget item without id");
+ }
+
+ return apiProjectBudgetItemsIdPatch({
+ path: { id: String(item.id) },
+ body: item,
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function deleteBudgetItem(itemId: number, session: Session) {
+ return apiProjectBudgetItemsIdDelete({
+ path: { id: String(itemId) },
+ headers: session.token.asHttpHeaders,
+ });
+}
+
+export async function getProjectDraftResources(
+ projectIri: string,
+ session: Session,
+): Promise {
+ const [rewardsResult, collaborationsResult, budgetItemsResult] = await Promise.all([
+ apiProjectRewardsGetCollection({
+ query: { project: projectIri, itemsPerPage: 100 },
+ headers: session.token.asHttpHeaders,
+ }),
+ apiProjectCollaborationsGetCollection({
+ query: { project: projectIri, itemsPerPage: 100 },
+ headers: session.token.asHttpHeaders,
+ }),
+ apiProjectBudgetItemsGetCollection({
+ query: { project: projectIri, itemsPerPage: 100 },
+ headers: session.token.asHttpHeaders,
+ }),
+ ]);
+
+ if (rewardsResult.error) throw rewardsResult.error;
+ if (collaborationsResult.error) throw collaborationsResult.error;
+ if (budgetItemsResult.error) throw budgetItemsResult.error;
+
+ const budgetItems = budgetItemsResult.data ?? [];
+
+ return {
+ rewards: rewardsResult.data ?? [],
+ collaborations: collaborationsResult.data ?? [],
+ budgetItems: {
+ minimum: budgetItems.filter((item) => item.deadline === "minimum"),
+ optimum: budgetItems.filter((item) => item.deadline === "optimum"),
+ },
+ };
+}