From aa87560b24a1e0f00a4aa0a39b063f1857121d70 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 16 Apr 2026 11:24:37 +0200 Subject: [PATCH 001/113] chore: add DexieJS dependencies --- package.json | 1 + pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/package.json b/package.json index 0ef139b5..83708590 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "bits-ui": "^2.14.4", "chart.js": "^4.5.1", "csrf": "^3.1.0", + "dexie": "^4.4.2", "dotenv": "^16.6.1", "flickity": "^3.0.0", "iso-3166-2": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82b755ed..32c56ad1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: csrf: specifier: ^3.1.0 version: 3.1.0 + dexie: + specifier: ^4.4.2 + version: 4.4.2 dotenv: specifier: ^16.6.1 version: 16.6.1 @@ -2355,6 +2358,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.4.2: + resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -7166,6 +7172,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.4.2: {} + diff@8.0.3: {} dlv@1.1.3: {} From e53b51c4cc4906ddf68a8d25d627bc03f0979072 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 16 Apr 2026 12:54:23 +0200 Subject: [PATCH 002/113] feat: start implementing projectDraft store with DexieJS --- src/stores/projectDraft.ts | 11 +++++++ src/utils/db.ts | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/stores/projectDraft.ts create mode 100644 src/utils/db.ts diff --git a/src/stores/projectDraft.ts b/src/stores/projectDraft.ts new file mode 100644 index 00000000..7f70e843 --- /dev/null +++ b/src/stores/projectDraft.ts @@ -0,0 +1,11 @@ +import { writable } from "svelte/store"; + +import type { Project } from "../openapi/client"; + +export const drafts = writable(); + +const STORAGE_PREFIX = "project"; + +export function buildStorageKey(hash: string) { + return `${STORAGE_PREFIX}:${hash}`; +} diff --git a/src/utils/db.ts b/src/utils/db.ts new file mode 100644 index 00000000..0ee53d5c --- /dev/null +++ b/src/utils/db.ts @@ -0,0 +1,63 @@ +import { Dexie, type EntityTable } from 'dexie'; + +import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from '../openapi/client'; + +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; + + // Validation tracking + touched: Set; + errors: Record; +} + +type Wizard = { + // Step navigation + currentStep: number; + + // 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 Draft extends Project { + draftId: string; + createProject: Project; + wizardForm: Wizard; +} + +export const db = new Dexie('sveltelivequery') as Dexie & { + drafts: EntityTable; +}; + +db.version(1).stores({ + drafts: "++draftId, createProject, wizardForm", +}); From faa14303ba7eaa23f788983943274416138ac7f1 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 16 Apr 2026 14:01:21 +0200 Subject: [PATCH 003/113] fix: move Draft and Wizard types to projectDraft store file --- src/stores/projectDraft.ts | 54 +++++++++++++++++++++++++++++++++++++- src/utils/db.ts | 54 +------------------------------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/stores/projectDraft.ts b/src/stores/projectDraft.ts index 7f70e843..ebc632fb 100644 --- a/src/stores/projectDraft.ts +++ b/src/stores/projectDraft.ts @@ -1,6 +1,58 @@ import { writable } from "svelte/store"; -import type { Project } from "../openapi/client"; +import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../openapi/client"; + +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; + + // Validation tracking + touched: Set; + errors: Record; +} + +export type Wizard = { + // Step navigation + currentStep: number; + + // 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 Draft extends Project { + draftId: string; + createProject: Project; + wizardForm: Wizard; +} export const drafts = writable(); diff --git a/src/utils/db.ts b/src/utils/db.ts index 0ee53d5c..c1f41945 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,58 +1,6 @@ import { Dexie, type EntityTable } from 'dexie'; -import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from '../openapi/client'; - -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; - - // Validation tracking - touched: Set; - errors: Record; -} - -type Wizard = { - // Step navigation - currentStep: number; - - // 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 Draft extends Project { - draftId: string; - createProject: Project; - wizardForm: Wizard; -} +import type { Draft } from '../stores/projectDraft'; export const db = new Dexie('sveltelivequery') as Dexie & { drafts: EntityTable; From 5a361687a3f3a4721c63b94889172e2f7967fd40 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Wed, 22 Apr 2026 14:05:37 +0200 Subject: [PATCH 004/113] fix: move DexieJS DB file to drafts folder in utils, and modify attributes --- src/utils/db.ts | 11 ----------- src/utils/drafts/db.ts | 11 +++++++++++ 2 files changed, 11 insertions(+), 11 deletions(-) delete mode 100644 src/utils/db.ts create mode 100644 src/utils/drafts/db.ts diff --git a/src/utils/db.ts b/src/utils/db.ts deleted file mode 100644 index c1f41945..00000000 --- a/src/utils/db.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Dexie, type EntityTable } from 'dexie'; - -import type { Draft } from '../stores/projectDraft'; - -export const db = new Dexie('sveltelivequery') as Dexie & { - drafts: EntityTable; -}; - -db.version(1).stores({ - drafts: "++draftId, createProject, wizardForm", -}); diff --git a/src/utils/drafts/db.ts b/src/utils/drafts/db.ts new file mode 100644 index 00000000..593298ce --- /dev/null +++ b/src/utils/drafts/db.ts @@ -0,0 +1,11 @@ +import { Dexie, type EntityTable } from 'dexie'; + +import type { Draft } from '../../stores/projectDraft'; + +export const db = new Dexie('goteo-project-drafts') as Dexie & { + drafts: EntityTable; +}; + +db.version(2).stores({ + drafts: "draftId, userId, [userId+updatedAt]", +}); From b3a25af72428495b024fa63cd3116f75dc671a2d Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Wed, 22 Apr 2026 14:08:26 +0200 Subject: [PATCH 005/113] feat: add all CRUD functions in projectDraft repository ts file (managing it with Dexie) + add all needed stores, functions and start implementing validation refactor in the new store --- src/stores/projectDraft.ts | 193 +++++++++++++++++++- src/utils/drafts/projectDraft.repository.ts | 46 +++++ 2 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 src/utils/drafts/projectDraft.repository.ts diff --git a/src/stores/projectDraft.ts b/src/stores/projectDraft.ts index ebc632fb..1e7bc746 100644 --- a/src/stores/projectDraft.ts +++ b/src/stores/projectDraft.ts @@ -1,6 +1,9 @@ -import { writable } from "svelte/store"; +import { writable, readable, get, derived } from "svelte/store"; import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../openapi/client"; +import { liveQuery } from "dexie"; +import { db } from "../utils/drafts/db"; +import { draftRepo } from "../utils/drafts/projectDraft.repository"; export interface WizardConfiguration { projectDeadline: "minimum" | "optimum"; // Default: minimum @@ -16,10 +19,6 @@ export interface WizardCampaignInfo { legacy: string; targetAudience: string; team: string; - - // Validation tracking - touched: Set; - errors: Record; } export type Wizard = { @@ -48,16 +47,190 @@ export type Wizard = { // aboutYou: WizardAboutYou; } -export interface Draft extends Project { +export interface Draft { draftId: string; + userId: string; + createProject: Project; wizardForm: Wizard; + + updatedAt: number; } -export const drafts = writable(); +export const drafts = writable([]); -const STORAGE_PREFIX = "project"; +export const currentDraft = writable(null); -export function buildStorageKey(hash: string) { - return `${STORAGE_PREFIX}:${hash}`; +export const wizard = derived(currentDraft, ($d) => $d?.wizardForm); +export const project = derived(currentDraft, ($d) => $d?.createProject); + +export const validationErrors = writable>({}); +export const touchedFields = writable>(new Set()); + +export function createDraftId() { + return crypto.randomUUID(); +} + +export function createDraftsStore(userId: string) { + 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?: Project) { + const draftId = createDraftId(); + const userId = "a"; + + const draft: Draft = { + draftId, + userId, + createProject: project ?? ({} as Partial as Project), + wizardForm: { + currentStep: 1, + configuration: { + projectDeadline: "minimum", + }, + campaignInfo: { + images: [], + video: "", + objectives: "", + legacy: "", + targetAudience: "", + team: "", + }, + rewards: [], + collaborations: [], + budgetItems: { + minimum: [], + optimum: [], + }, + }, + updatedAt: Date.now(), + }; + + await draftRepo.create(draft); + currentDraft.set(draft); + + return draftId; +} + +export async function loadDraft(userId: string, draftId: string) { + const draft = await draftRepo.get(draftId, userId); + + if (!draft) return false; + + currentDraft.set(draft); + return true; +} + +let saveTimer: ReturnType | null = null; + +/** + * Draft Autosave + * + * Autosaves the current draft into the draft indexedDB repository + */ +function persistDraft() { + if (saveTimer) clearTimeout(saveTimer); + + saveTimer = setTimeout(async () => { + const draft = get(currentDraft); + if (!draft) return; + + const errors = validateDraft(draft); + + validationErrors.set(errors); + + if (Object.keys(errors).length > 0) { + return; + } + + await draftRepo.update(draft.draftId, draft.userId, draft); + }, 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) { + currentDraft.update((draft) => { + if (!draft) return draft; + + const updated = { + ...draft, + wizardForm: { + ...draft.wizardForm, + ...data, + }, + }; + + return updated; + }); + + persistDraft(); +} + +/** + * Update create Project form data + * + * Merges new project 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, + createProject: { + ...draft.createProject, + ...data, + }, + }; + + return updated; + }); + + persistDraft(); +} + +export async function deleteDraft(draftId: string, userId: string) { + await draftRepo.delete(draftId, userId); + + const current = get(currentDraft); + if (current?.draftId === draftId) { + currentDraft.set(null); + } +} + +function validateDraft(draft: Draft): Record { + const errors: Record = {}; + + const campaign = draft.wizardForm.campaignInfo; + + if (!campaign.objectives || campaign.objectives.length < 50) { + errors.objectives = "min_length"; + } + + if (!campaign.images.length && !campaign.video) { + errors.media = "required"; + } + + return errors; } diff --git a/src/utils/drafts/projectDraft.repository.ts b/src/utils/drafts/projectDraft.repository.ts new file mode 100644 index 00000000..b42a8b42 --- /dev/null +++ b/src/utils/drafts/projectDraft.repository.ts @@ -0,0 +1,46 @@ +import { db } from "./db"; +import type { Draft } from "../../stores/projectDraft"; +import Dexie from "dexie"; + +export const draftRepo = { + async create(draft: Draft) { + await db.drafts.put(draft); + return draft; + }, + + async update(draftId: string, userId: string, data: Partial) { + const existing = await db.drafts.get(draftId); + + if (!existing || existing.userId !== userId) return; + + await db.drafts.put({ + ...existing, + ...data, + updatedAt: Date.now(), + }); + }, + + async get(draftId: string, userId: string) { + const draft = await db.drafts.get(draftId); + + if (!draft || draft.userId !== userId) return undefined; + + return draft; + }, + + async getAll(userId: string) { + return db.drafts + .where("[userId+updatedAt]") + .between([userId, Dexie.minKey], [userId, Dexie.maxKey]) + .reverse() + .toArray() + }, + + async delete(draftId: string, userId: string) { + const draft = await db.drafts.get(draftId); + + if (!draft || draft.userId !== userId) return; + + await db.drafts.delete(draftId); + }, +}; From 2da77ff1f78da61d0214adf52e9ed6f28223ef09 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Wed, 22 Apr 2026 18:05:41 +0200 Subject: [PATCH 006/113] fix: move validation to new file and change folders + add all validation functions for all wizard steps at the moment --- src/stores/drafts/draftValidation.ts | 143 ++++++++++++++++++ src/stores/{ => drafts}/projectDraft.ts | 24 +-- src/utils/drafts/db.ts | 2 +- ...ojectDraft.repository.ts => repository.ts} | 2 +- 4 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 src/stores/drafts/draftValidation.ts rename src/stores/{ => drafts}/projectDraft.ts (89%) rename src/utils/drafts/{projectDraft.repository.ts => repository.ts} (94%) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts new file mode 100644 index 00000000..61d1fbfe --- /dev/null +++ b/src/stores/drafts/draftValidation.ts @@ -0,0 +1,143 @@ +import { get, writable } from "svelte/store"; +import type { Draft, Wizard } from "./projectDraft"; +import { cyrb53 } from "../../utils/hash"; + +export type ValidationErrors = Record; + +export const validationErrors = writable({}); + +export function validateDraft(draft: Draft): ValidationErrors { + return { + ...validateConfiguration(draft.wizardForm), + ...validateCampaignInfo(draft.wizardForm), + ...validateRewards(draft.wizardForm), + ...validateCollaborations(draft.wizardForm), + ...validateBudget(draft.wizardForm), + }; +} + +export function validateField(field: string, draft: Draft) { + const errors = get(validationErrors); + + switch (field) { + case "objectives": + if (draft.wizardForm.campaignInfo.objectives.length < 50) { + errors.objectives = "min_length"; + } else { + delete errors.objectives; + } + break; + } + + validationErrors.set({ ...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 || ""; +} + +export function validateConfiguration(wizard: Wizard): ValidationErrors { + const errors: ValidationErrors = {}; + + if (!wizard.configuration.projectDeadline) { + errors.projectDeadline = "validation.projectDeadline.required"; + } + + return errors; +} + +export function validateCampaignInfo(wizard: Wizard): ValidationErrors { + const data = wizard.campaignInfo; + const errors: ValidationErrors = {}; + + if (data.images.length === 0 && !data.video) { + errors.media = "validation.media.required"; + } + + const objectives = stripHtml(data.objectives); + if (!objectives) { + errors.objectives = "validation.objectives.required"; + } else if (objectives.length < 50) { + errors.objectives = "validation.objectives.min"; + } + + const legacy = stripHtml(data.legacy); + if (!legacy) { + errors.legacy = "validation.legacy.required"; + } + + const target = stripHtml(data.targetAudience); + if (!target) { + errors.targetAudience = "validation.target.required"; + } + + const team = stripHtml(data.team); + if (!team) { + errors.team = "validation.team.required"; + } + + return errors; +} + +export function validateRewards(wizard: Wizard): ValidationErrors { + const errors: ValidationErrors = {}; + + wizard.rewards.forEach((reward) => { + const hash = cyrb53(JSON.stringify(reward)); + + if (!reward.title?.trim()) { + errors[`reward_title_${hash}`] = "validation.reward.title"; + } + + if (!reward.money?.amount || reward.money.amount <= 0) { + errors[`reward_amount_${hash}`] = "validation.reward.amount"; + } + + if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) { + errors[`reward_units_${hash}`] = "validation.reward.units"; + } + }); + + return errors; +} + +export function validateCollaborations(wizard: Wizard): ValidationErrors { + const errors: ValidationErrors = {}; + + wizard.collaborations.forEach((c) => { + const hash = cyrb53(JSON.stringify(c)); + + if (!c.title?.trim()) { + errors[`collab_title_${hash}`] = "validation.collab.title"; + } + + if (!c.description?.trim()) { + errors[`collab_desc_${hash}`] = "validation.collab.description"; + } + }); + + return errors; +} + +export function validateBudget(wizard: Wizard): ValidationErrors { + const errors: ValidationErrors = {}; + + wizard.budgetItems.minimum.forEach((item) => { + const hash = cyrb53(JSON.stringify(item)); + + if (!item.title?.trim()) { + errors[`budget_title_${hash}`] = "validation.budget.title"; + } + + if (!item.money?.amount || item.money.amount <= 0) { + errors[`budget_amount_${hash}`] = "validation.budget.amount"; + } + }); + + return errors; +} diff --git a/src/stores/projectDraft.ts b/src/stores/drafts/projectDraft.ts similarity index 89% rename from src/stores/projectDraft.ts rename to src/stores/drafts/projectDraft.ts index 1e7bc746..7d62e2e3 100644 --- a/src/stores/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -1,9 +1,10 @@ import { writable, readable, get, derived } from "svelte/store"; -import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../openapi/client"; +import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; import { liveQuery } from "dexie"; -import { db } from "../utils/drafts/db"; -import { draftRepo } from "../utils/drafts/projectDraft.repository"; +import { db } from "../../utils/drafts/db"; +import { draftRepo } from "../../utils/drafts/repository"; +import { validateDraft, validationErrors } from "./draftValidation"; export interface WizardConfiguration { projectDeadline: "minimum" | "optimum"; // Default: minimum @@ -64,7 +65,6 @@ export const currentDraft = writable(null); export const wizard = derived(currentDraft, ($d) => $d?.wizardForm); export const project = derived(currentDraft, ($d) => $d?.createProject); -export const validationErrors = writable>({}); export const touchedFields = writable>(new Set()); export function createDraftId() { @@ -218,19 +218,3 @@ export async function deleteDraft(draftId: string, userId: string) { currentDraft.set(null); } } - -function validateDraft(draft: Draft): Record { - const errors: Record = {}; - - const campaign = draft.wizardForm.campaignInfo; - - if (!campaign.objectives || campaign.objectives.length < 50) { - errors.objectives = "min_length"; - } - - if (!campaign.images.length && !campaign.video) { - errors.media = "required"; - } - - return errors; -} diff --git a/src/utils/drafts/db.ts b/src/utils/drafts/db.ts index 593298ce..8f81e2f4 100644 --- a/src/utils/drafts/db.ts +++ b/src/utils/drafts/db.ts @@ -1,6 +1,6 @@ import { Dexie, type EntityTable } from 'dexie'; -import type { Draft } from '../../stores/projectDraft'; +import type { Draft } from '../../stores/drafts/projectDraft'; export const db = new Dexie('goteo-project-drafts') as Dexie & { drafts: EntityTable; diff --git a/src/utils/drafts/projectDraft.repository.ts b/src/utils/drafts/repository.ts similarity index 94% rename from src/utils/drafts/projectDraft.repository.ts rename to src/utils/drafts/repository.ts index b42a8b42..ffe0a8d9 100644 --- a/src/utils/drafts/projectDraft.repository.ts +++ b/src/utils/drafts/repository.ts @@ -1,5 +1,5 @@ import { db } from "./db"; -import type { Draft } from "../../stores/projectDraft"; +import type { Draft } from "../../stores/drafts/projectDraft"; import Dexie from "dexie"; export const draftRepo = { From bec7b157da6e7278fee552f964c906987f88d117 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 27 Apr 2026 10:11:17 +0200 Subject: [PATCH 007/113] feat: add isDraftValid derived store to draftValidation.ts --- src/stores/drafts/draftValidation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 61d1fbfe..8a306513 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -1,10 +1,13 @@ -import { get, writable } from "svelte/store"; +import { derived, get, writable } from "svelte/store"; import type { Draft, Wizard } from "./projectDraft"; import { cyrb53 } from "../../utils/hash"; export type ValidationErrors = Record; export const validationErrors = writable({}); +export const isDraftValid = derived(validationErrors, ($errors) => { + return Object.keys($errors).length === 0; +}); export function validateDraft(draft: Draft): ValidationErrors { return { @@ -17,7 +20,7 @@ export function validateDraft(draft: Draft): ValidationErrors { } export function validateField(field: string, draft: Draft) { - const errors = get(validationErrors); + const errors = get(validationErrors); switch (field) { case "objectives": From db19da4b158896c973bcae1b2cbc9a012f0db35c Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 27 Apr 2026 10:12:31 +0200 Subject: [PATCH 008/113] feat: add getUserId function from oauth session and change userId type to number in Draft type --- src/stores/drafts/projectDraft.ts | 21 ++++++++++++++++----- src/utils/drafts/repository.ts | 8 ++++---- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 7d62e2e3..868dc97a 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -5,6 +5,7 @@ import { liveQuery } from "dexie"; import { db } from "../../utils/drafts/db"; import { draftRepo } from "../../utils/drafts/repository"; import { validateDraft, validationErrors } from "./draftValidation"; +import { session } from "../../auth/store"; export interface WizardConfiguration { projectDeadline: "minimum" | "optimum"; // Default: minimum @@ -50,7 +51,7 @@ export type Wizard = { export interface Draft { draftId: string; - userId: string; + userId: number; createProject: Project; wizardForm: Wizard; @@ -71,7 +72,17 @@ export function createDraftId() { return crypto.randomUUID(); } -export function createDraftsStore(userId: string) { +function getUserId(): number { + const s = get(session); + + if (!s?.user?.id) { + throw new Error("User not authenticated"); + } + + return s.user.id; +} + +export function createDraftsStore(userId: number) { return readable([], (set) => { const subscription = liveQuery(() => db.drafts @@ -90,7 +101,7 @@ export function createDraftsStore(userId: string) { export async function createDraft(project?: Project) { const draftId = createDraftId(); - const userId = "a"; + const userId = getUserId(); const draft: Draft = { draftId, @@ -125,7 +136,7 @@ export async function createDraft(project?: Project) { return draftId; } -export async function loadDraft(userId: string, draftId: string) { +export async function loadDraft(userId: number, draftId: string) { const draft = await draftRepo.get(draftId, userId); if (!draft) return false; @@ -210,7 +221,7 @@ export function updateProject(data: Partial) { persistDraft(); } -export async function deleteDraft(draftId: string, userId: string) { +export async function deleteDraft(draftId: string, userId: number) { await draftRepo.delete(draftId, userId); const current = get(currentDraft); diff --git a/src/utils/drafts/repository.ts b/src/utils/drafts/repository.ts index ffe0a8d9..3914cf5d 100644 --- a/src/utils/drafts/repository.ts +++ b/src/utils/drafts/repository.ts @@ -8,7 +8,7 @@ export const draftRepo = { return draft; }, - async update(draftId: string, userId: string, data: Partial) { + async update(draftId: string, userId: number, data: Partial) { const existing = await db.drafts.get(draftId); if (!existing || existing.userId !== userId) return; @@ -20,7 +20,7 @@ export const draftRepo = { }); }, - async get(draftId: string, userId: string) { + async get(draftId: string, userId: number) { const draft = await db.drafts.get(draftId); if (!draft || draft.userId !== userId) return undefined; @@ -28,7 +28,7 @@ export const draftRepo = { return draft; }, - async getAll(userId: string) { + async getAll(userId: number) { return db.drafts .where("[userId+updatedAt]") .between([userId, Dexie.minKey], [userId, Dexie.maxKey]) @@ -36,7 +36,7 @@ export const draftRepo = { .toArray() }, - async delete(draftId: string, userId: string) { + async delete(draftId: string, userId: number) { const draft = await db.drafts.get(draftId); if (!draft || draft.userId !== userId) return; From ff4d4b57c08b8fae766081a1b4661afff160f741 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 27 Apr 2026 12:28:20 +0200 Subject: [PATCH 009/113] feat: add create form validation + change configuration & campaign info steps validation --- src/stores/drafts/draftValidation.ts | 146 +++++++++++++++++++++++---- src/stores/drafts/projectDraft.ts | 22 +++- 2 files changed, 146 insertions(+), 22 deletions(-) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 8a306513..a3e98d2a 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -1,6 +1,8 @@ import { derived, get, writable } from "svelte/store"; -import type { Draft, Wizard } from "./projectDraft"; +import { currentDraft, type Draft, type Wizard } from "./projectDraft"; import { cyrb53 } from "../../utils/hash"; +import { projectCreationSchema } from "../../pages/[...lang]/create/validation"; +import z from "zod"; export type ValidationErrors = Record; @@ -11,6 +13,7 @@ export const isDraftValid = derived(validationErrors, ($errors) => { export function validateDraft(draft: Draft): ValidationErrors { return { + ...validateCreateForm(), ...validateConfiguration(draft.wizardForm), ...validateCampaignInfo(draft.wizardForm), ...validateRewards(draft.wizardForm), @@ -19,6 +22,29 @@ export function validateDraft(draft: Draft): ValidationErrors { }; } +/** + * Validates the entire create project form and updates the validation errors store. + * Returns true if the form is valid, false otherwise. + */ +export function validateCreateForm(): boolean { + const result = projectCreationSchema.safeParse(get(currentDraft)); + + 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; + } + }); + validationErrors.set(errors); + return false; + } else { + validationErrors.set({}); + return true; + } +} + export function validateField(field: string, draft: Draft) { const errors = get(validationErrors); @@ -44,44 +70,122 @@ export function stripHtml(html: string): string { return div.textContent || div.innerText || ""; } -export function validateConfiguration(wizard: Wizard): ValidationErrors { - const errors: ValidationErrors = {}; +/** + * Validation schema for Configuration step + */ +export const configurationSchema = z.object({ + projectDeadline: z.union([z.literal(1), z.literal(2)]), +}); - if (!wizard.configuration.projectDeadline) { - errors.projectDeadline = "validation.projectDeadline.required"; +/** + * 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(wizard: Wizard): boolean { + 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; + } + }); + validationErrors.set(errors); + return false; } - return errors; + validationErrors.set({}); + return true; } +/** + * 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 = "validation.media.required"; + errors.media = "pages.project.edit.rewards.validationn_info.reward.media.required"; } - const objectives = stripHtml(data.objectives); - if (!objectives) { - errors.objectives = "validation.objectives.required"; - } else if (objectives.length < 50) { - errors.objectives = "validation.objectives.min"; + // 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"; } - const legacy = stripHtml(data.legacy); - if (!legacy) { - errors.legacy = "validation.legacy.required"; + // 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"; } - const target = stripHtml(data.targetAudience); - if (!target) { - errors.targetAudience = "validation.target.required"; + // 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"; } - const team = stripHtml(data.team); - if (!team) { - errors.team = "validation.team.required"; + // 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; diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 868dc97a..0930e14f 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -82,7 +82,7 @@ function getUserId(): number { return s.user.id; } -export function createDraftsStore(userId: number) { +export function createDraftStore(userId: number) { return readable([], (set) => { const subscription = liveQuery(() => db.drafts @@ -145,6 +145,26 @@ export async function loadDraft(userId: number, draftId: string) { return true; } +/** + * 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; /** From 3033fb193e5e2a98ab1c033ee637768db5bb09c3 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 27 Apr 2026 14:09:08 +0200 Subject: [PATCH 010/113] fix: adding/modifying validation functions in draftValidation.ts + start modifying/refactoring persistDraft function in projectDraft.ts --- src/stores/drafts/draftValidation.ts | 159 +++++++++++++++++---------- src/stores/drafts/projectDraft.ts | 17 +-- src/utils/drafts/repository.ts | 6 +- 3 files changed, 112 insertions(+), 70 deletions(-) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index a3e98d2a..b4b885dd 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -1,9 +1,12 @@ import { derived, get, writable } from "svelte/store"; -import { currentDraft, type Draft, type Wizard } from "./projectDraft"; -import { cyrb53 } from "../../utils/hash"; -import { projectCreationSchema } from "../../pages/[...lang]/create/validation"; import z from "zod"; +import { currentDraft, type Draft, type Wizard } from "./projectDraft"; +import { projectCreationSchema } from "../../pages/[...lang]/create/validation"; +import { cyrb53 } from "../../utils/hash"; + +import type { ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; + export type ValidationErrors = Record; export const validationErrors = writable({}); @@ -11,17 +14,6 @@ export const isDraftValid = derived(validationErrors, ($errors) => { return Object.keys($errors).length === 0; }); -export function validateDraft(draft: Draft): ValidationErrors { - return { - ...validateCreateForm(), - ...validateConfiguration(draft.wizardForm), - ...validateCampaignInfo(draft.wizardForm), - ...validateRewards(draft.wizardForm), - ...validateCollaborations(draft.wizardForm), - ...validateBudget(draft.wizardForm), - }; -} - /** * Validates the entire create project form and updates the validation errors store. * Returns true if the form is valid, false otherwise. @@ -45,20 +37,13 @@ export function validateCreateForm(): boolean { } } -export function validateField(field: string, draft: Draft) { - const errors = get(validationErrors); +export function validateDraftPublish(draft: Draft): ValidationErrors { + const wizard = draft.wizardForm; - switch (field) { - case "objectives": - if (draft.wizardForm.campaignInfo.objectives.length < 50) { - errors.objectives = "min_length"; - } else { - delete errors.objectives; - } - break; - } - - validationErrors.set({ ...errors }); + return { + ...validateConfiguration(wizard), + ...validateCampaignInfo(wizard), + }; } export function stripHtml(html: string): string { @@ -191,60 +176,112 @@ export function validateCampaignInfo(wizard: Wizard): ValidationErrors { return errors; } -export function validateRewards(wizard: Wizard): ValidationErrors { +export function validateReward(reward: ProjectReward): ValidationErrors { const errors: ValidationErrors = {}; + const hash = cyrb53(JSON.stringify(reward)); - wizard.rewards.forEach((reward) => { - const hash = cyrb53(JSON.stringify(reward)); + if (!reward.title.trim()) { + errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title"; + } - if (!reward.title?.trim()) { - errors[`reward_title_${hash}`] = "validation.reward.title"; - } + if (!reward.money.amount || reward.money.amount <= 0) { + errors[`reward_error_amount_${hash}`] = "pages.project.edit.rewards.validation.amount"; + } - if (!reward.money?.amount || reward.money.amount <= 0) { - errors[`reward_amount_${hash}`] = "validation.reward.amount"; - } - - if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) { - errors[`reward_units_${hash}`] = "validation.reward.units"; - } - }); + if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) { + errors[`reward_error_units_${hash}`] = "pages.project.edit.rewards.validation.units"; + } return errors; } -export function validateCollaborations(wizard: Wizard): ValidationErrors { +export function validateCollaboration(collab: ProjectCollaboration): ValidationErrors { const errors: ValidationErrors = {}; + const hash = cyrb53(JSON.stringify(collab)); - wizard.collaborations.forEach((c) => { - const hash = cyrb53(JSON.stringify(c)); + if (!collab.title.trim()) { + errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title"; + } - if (!c.title?.trim()) { - errors[`collab_title_${hash}`] = "validation.collab.title"; - } + if (collab.description && collab.description.length > 1000) { + errors[`collab_error_description_too_long_${hash}`] = + "pages.project.edit.collaborations.validation.descriptionTooLong"; + } - if (!c.description?.trim()) { - errors[`collab_desc_${hash}`] = "validation.collab.description"; - } - }); + if (!collab.description.trim()) { + errors[`collab_error_description_${hash}`] = + "pages.project.edit.collaborations.validation.description"; + } return errors; } -export function validateBudget(wizard: Wizard): ValidationErrors { +export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors { const errors: ValidationErrors = {}; + const hash = cyrb53(JSON.stringify(item)); - wizard.budgetItems.minimum.forEach((item) => { - const hash = cyrb53(JSON.stringify(item)); + if (!item.title.trim()) { + errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title."; + } - if (!item.title?.trim()) { - errors[`budget_title_${hash}`] = "validation.budget.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_amount_${hash}`] = "validation.budget.amount"; - } - }); + 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 budget = draft.createProject.budget; + const errors: ValidationErrors = {}; + + 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?.minimum?.money?.amount) { + errors.minimum_length = "pages.project.edit.budget.validation.amountMinimum"; + } + + return errors; +} + +export function validateField(field: string, draft: Draft) { + const errors = get(validationErrors); + + switch (field) { + case "objectives": + if (draft.wizardForm.campaignInfo.objectives.length < 50) { + errors.objectives = "min_length"; + } else { + delete errors.objectives; + } + break; + } + + validationErrors.set({ ...errors }); +} diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 0930e14f..3de9fab3 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -1,11 +1,12 @@ +import { liveQuery } from "dexie"; import { writable, readable, get, derived } from "svelte/store"; -import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; -import { liveQuery } from "dexie"; +import { validateCreateForm, validationErrors } from "./draftValidation"; +import { session } from "../../auth/store"; import { db } from "../../utils/drafts/db"; import { draftRepo } from "../../utils/drafts/repository"; -import { validateDraft, validationErrors } from "./draftValidation"; -import { session } from "../../auth/store"; + +import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; export interface WizardConfiguration { projectDeadline: "minimum" | "optimum"; // Default: minimum @@ -59,7 +60,7 @@ export interface Draft { updatedAt: number; } -export const drafts = writable([]); +export const drafts = readable(createDraftStore(getUserId())); export const currentDraft = writable(null); @@ -219,7 +220,7 @@ export function updateWizard(data: Partial) { /** * Update create Project form data * - * Merges new project updates with already existing project 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 */ @@ -238,7 +239,9 @@ export function updateProject(data: Partial) { return updated; }); - persistDraft(); + if (validateCreateForm()) { + persistDraft(); + } else return; } export async function deleteDraft(draftId: string, userId: number) { diff --git a/src/utils/drafts/repository.ts b/src/utils/drafts/repository.ts index 3914cf5d..34830318 100644 --- a/src/utils/drafts/repository.ts +++ b/src/utils/drafts/repository.ts @@ -1,7 +1,9 @@ -import { db } from "./db"; -import type { Draft } from "../../stores/drafts/projectDraft"; 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); From 96eede7c45dfc201d3b3762d44238c41cdf69f45 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 4 May 2026 09:53:55 +0200 Subject: [PATCH 011/113] fix: modify autosave draft function --- src/stores/drafts/projectDraft.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 3de9fab3..9d636f53 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -168,11 +168,6 @@ export function markFieldAsTouched(fieldName: string) { let saveTimer: ReturnType | null = null; -/** - * Draft Autosave - * - * Autosaves the current draft into the draft indexedDB repository - */ function persistDraft() { if (saveTimer) clearTimeout(saveTimer); @@ -180,15 +175,14 @@ function persistDraft() { const draft = get(currentDraft); if (!draft) return; - const errors = validateDraft(draft); - - validationErrors.set(errors); - - if (Object.keys(errors).length > 0) { - return; + try { + await draftRepo.update(draft.draftId, draft.userId, { + ...draft, + updatedAt: Date.now(), + }); + } catch (error) { + console.error("Failed to persist draft:", error); } - - await draftRepo.update(draft.draftId, draft.userId, draft); }, 1000); } From e92d4d2018302dabb01a26813aa1f8246cf76420 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 4 May 2026 09:55:37 +0200 Subject: [PATCH 012/113] fix: replace cyrb53 with murmurhash3 in draftValidation ts file --- src/stores/drafts/draftValidation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index b4b885dd..299d297a 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -2,10 +2,10 @@ import { derived, get, writable } from "svelte/store"; import z from "zod"; import { currentDraft, type Draft, type Wizard } from "./projectDraft"; -import { projectCreationSchema } from "../../pages/[...lang]/create/validation"; -import { cyrb53 } from "../../utils/hash"; +import { projectCreationSchema } from "../../pages/[...locale]/create/validation"; import type { ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; +import murmur from "murmurhash-js"; export type ValidationErrors = Record; @@ -178,7 +178,7 @@ export function validateCampaignInfo(wizard: Wizard): ValidationErrors { export function validateReward(reward: ProjectReward): ValidationErrors { const errors: ValidationErrors = {}; - const hash = cyrb53(JSON.stringify(reward)); + const hash = murmur(JSON.stringify(reward)); if (!reward.title.trim()) { errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title"; @@ -197,7 +197,7 @@ export function validateReward(reward: ProjectReward): ValidationErrors { export function validateCollaboration(collab: ProjectCollaboration): ValidationErrors { const errors: ValidationErrors = {}; - const hash = cyrb53(JSON.stringify(collab)); + const hash = murmur(JSON.stringify(collab)); if (!collab.title.trim()) { errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title"; @@ -218,7 +218,7 @@ export function validateCollaboration(collab: ProjectCollaboration): ValidationE export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors { const errors: ValidationErrors = {}; - const hash = cyrb53(JSON.stringify(item)); + const hash = murmur(JSON.stringify(item)); if (!item.title.trim()) { errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title."; From 4f634c5efee5a1d0025e27a38c975317345db973 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 4 May 2026 13:50:14 +0200 Subject: [PATCH 013/113] feat: replace old CreateProjectForm draft handling with new IndexedDB logic + change createProject type in Draft to match the POST method type + add budget (amount) to wizardForm Co-authored-by: Copilot --- src/components/library/DateInput.svelte | 4 +- src/components/library/TextInput.svelte | 5 +- .../create/CreateProjectForm.svelte | 162 ++++++++++-------- src/stores/drafts/draftValidation.ts | 70 ++++++-- src/stores/drafts/projectDraft.ts | 18 +- 5 files changed, 163 insertions(+), 96 deletions(-) diff --git a/src/components/library/DateInput.svelte b/src/components/library/DateInput.svelte index b2a6ecc5..93003aae 100644 --- a/src/components/library/DateInput.svelte +++ b/src/components/library/DateInput.svelte @@ -16,7 +16,7 @@ onBlur = undefined, onInput = undefined, }: { - value?: Date; + value?: string | Date; id?: string; name?: string; required?: boolean; @@ -93,7 +93,7 @@ const maxString = $derived(toDateString(max)); // Convert current value to string for HTML input - const valueString = $derived(dateToString(value)); + const valueString = $derived(typeof value === "string" ? value : dateToString(value));
diff --git a/src/components/library/TextInput.svelte b/src/components/library/TextInput.svelte index 30b3054f..c7baf4c1 100644 --- a/src/components/library/TextInput.svelte +++ b/src/components/library/TextInput.svelte @@ -13,8 +13,9 @@ labelText = undefined, helperText = undefined, error = undefined, + onBlur = undefined, }: { - value?: string; + value?: string | undefined; id?: string; name?: string; placeholder?: string; @@ -25,6 +26,7 @@ labelText?: string; helperText?: string; error?: string; + onBlur?: () => void; } = $props(); const generatedId = $props.id(); @@ -43,6 +45,7 @@ import { onDestroy } from "svelte"; - import { - draft, - validationErrors, - touchedFields, - isFormValid, - validateField, - validateForm, - markFieldAsTouched, - } from "./project-draft"; import BaseCard from "../../../components/BaseCard.svelte"; import Button from "../../../components/library/Button.svelte"; import CategorySelect from "../../../components/library/CategorySelect.svelte"; import DateInput from "../../../components/library/DateInput.svelte"; import TextInput from "../../../components/library/TextInput.svelte"; import { t } from "../../../i18n/store"; + import { + apiProjectsPost, + type Category, + type ProjectProjectCreationDto, + } from "../../../openapi/client"; + import { + isFormValid, + validateCreateForm, + validateField, + validationErrors, + } from "../../../stores/drafts/draftValidation"; + import { + currentDraft, + markFieldAsTouched, + touchedFields, + } from "../../../stores/drafts/projectDraft"; import { categories } from "../../../utils/categories"; import { formatCurrency } from "../../../utils/currencies"; - import type { ProjectDraft } from "./project-draft"; - - // import { apiProjectsPost } from "../../../openapi/client"; // TODO: Use when API integration is complete - const categoriesOptions = categories.map((categories) => { return { id: categories.id, text: $t(categories.translationKey) }; }); + let createProjectDraft = $derived( + $currentDraft?.createProject || { + title: "", + subtitle: "", + categories: [], + release: undefined, + }, + ); + // Track if form has been submitted once (for showing all errors) - let submitted = false; + let submitted = $state(false); // Loading state for form submission - let isSubmitting = false; + let isSubmitting = $state(false); // API error message - let apiError: string | null = null; + let apiError: string | null = $state(null); // Success state - let submitSuccess = false; + let submitSuccess = $state(false); // Debounce timer for real-time validation let debounceTimer: ReturnType | null = null; - function handleFieldBlur(fieldName: keyof ProjectDraft) { + function handleFieldBlur(fieldName: keyof ProjectProjectCreationDto) { + if (!$currentDraft) return; + markFieldAsTouched(fieldName); - validateField(fieldName, $draft[fieldName]); + validateField(fieldName, $currentDraft.createProject[fieldName]); } /** * Handles field value changes with type-safe updates. * Uses generic typing to ensure type safety without bypassing TypeScript checks. */ - function handleFieldChange(fieldName: K, value: ProjectDraft[K]) { + function handleFieldChange( + fieldName: K, + value: ProjectProjectCreationDto[K], + ) { + if (!$currentDraft) return; // Update the draft value with proper typing - $draft[fieldName] = value; + $currentDraft.createProject[fieldName] = value; // Only validate on change if field has been touched if ($touchedFields.has(fieldName) || submitted) { @@ -74,7 +92,7 @@ }); function handleCategoryChange(selected: { id: number | string; text: string }[]) { - const categoryIds = selected.map((s) => s.id.toString()); + const categoryIds = selected.map((s) => s.id.toString()) as Category[]; handleFieldChange("categories", categoryIds); } @@ -83,7 +101,7 @@ apiError = null; // Validate entire form - const isValid = validateForm(); + const isValid = validateCreateForm(); if (!isValid) { // Scroll to error summary @@ -112,43 +130,36 @@ // The API expects: title, subtitle, category (single), territory, description // Need to update the form to collect the correct fields or map them appropriately - // For now, simulate submission in dev mode - if (import.meta.env.DEV) { - console.log("Form is valid, would submit:", $draft); - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - submitSuccess = true; - } else { - // TODO: Implement actual API submission when form fields match API requirements - /* - const { data, error } = await apiProjectsPost({ - body: { - title: $draft.title, - subtitle: $draft.subtitle, - category: $draft.categories[0], // Map first category - territory: // Need territory field - description: // Need description field - }, - }); + // TODO: Implement actual API submission when form fields match API requirements + if (!$currentDraft) { + apiError = "Draft data is missing. Please refresh the page and try again."; + return; + } - if (error) { - // Handle API validation errors - if ('violations' in error && error.violations) { - error.violations.forEach((violation) => { - const field = violation.propertyPath as keyof ProjectDraft; - if (field) { - $validationErrors[field] = violation.message || 'Invalid value'; - } - }); - } else { - apiError = "An unexpected error occurred. Please try again."; - } - } else if (data) { - submitSuccess = true; - // Redirect to project page + const { data, error } = await apiProjectsPost({ + body: { + title: $currentDraft.createProject.title, + subtitle: $currentDraft.createProject.subtitle, + categories: $currentDraft.createProject.categories as Category[], // Map all categories + release: $currentDraft.createProject.release, // Map release date + }, + }); + + if (error) { + // Handle API validation errors + if ("violations" in error && error.violations) { + error.violations.forEach((violation) => { + const field = violation.propertyPath as keyof ProjectProjectCreationDto; + if (field) { + $validationErrors[field] = violation.message || "Invalid value"; + } + }); + } else { + apiError = "An unexpected error occurred. Please try again."; } - */ - apiError = "Project creation is not yet fully implemented."; + } else if (data) { + submitSuccess = true; + // Redirect to project page } } catch (err) { // Handle unexpected errors @@ -172,10 +183,15 @@ minDate.setDate(minDate.getDate() + 14); return minDate; } + + $effect(() => { + console.log("Current draft state:", $currentDraft); + console.log("Current validation errors:", $validationErrors); + });
-
+

{$t("pages.project.create.title")} @@ -228,7 +244,7 @@ handleFieldBlur("title")} /> @@ -237,12 +253,12 @@ id="subtitle" name="subtitle" placeholder={$t("pages.project.create.description.subtitlePrompt")} - class="h-[240px] w-full resize-none rounded-md border p-[16px] {shouldShowError( + class="h-60 w-full resize-none rounded-md border p-4 {shouldShowError( 'subtitle', ) ? 'border-red-500' : 'border-[#855a96]'}" - bind:value={$draft.subtitle} + bind:value={createProjectDraft.subtitle} onblur={() => handleFieldBlur("subtitle")} aria-invalid={shouldShowError("subtitle")} aria-describedby={shouldShowError("subtitle") ? "subtitle-error" : undefined} @@ -277,11 +293,11 @@

handleFieldBlur("release")} - onInput={(date) => handleFieldChange("release", date)} + onInput={(date) => handleFieldChange("release", date.toDateString())} />

{#if apiError} @@ -303,17 +319,25 @@

- -

- {$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/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 299d297a..714a1401 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -1,11 +1,12 @@ +import murmur from "murmurhash-js"; import { derived, get, writable } from "svelte/store"; import z from "zod"; import { currentDraft, type Draft, type Wizard } from "./projectDraft"; import { projectCreationSchema } from "../../pages/[...locale]/create/validation"; -import type { ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; -import murmur from "murmurhash-js"; +import type { ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto, ProjectReward } from "../../openapi/client"; + export type ValidationErrors = Record; @@ -14,6 +15,26 @@ export const isDraftValid = derived(validationErrors, ($errors) => { 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 isFormValid = derived([currentDraft, 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?.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; +}); + /** * Validates the entire create project form and updates the validation errors store. * Returns true if the form is valid, false otherwise. @@ -37,7 +58,7 @@ export function validateCreateForm(): boolean { } } -export function validateDraftPublish(draft: Draft): ValidationErrors { +export function validateDraftToPublish(draft: Draft): ValidationErrors { const wizard = draft.wizardForm; return { @@ -250,7 +271,7 @@ export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors { export function validateBudgetAmount(draft: Draft) { const budgetItems = draft.wizardForm.budgetItems; - const budget = draft.createProject.budget; + const budget = draft.wizardForm.budget; const errors: ValidationErrors = {}; if (budgetItems.minimum.length <= 0) { @@ -270,18 +291,37 @@ export function validateBudgetAmount(draft: Draft) { return errors; } -export function validateField(field: string, draft: Draft) { - const errors = get(validationErrors); +/** + * 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) { + // Type guard to check if field exists in schema + type SchemaFields = keyof typeof projectCreationSchema.shape; - switch (field) { - case "objectives": - if (draft.wizardForm.campaignInfo.objectives.length < 50) { - errors.objectives = "min_length"; - } else { - delete errors.objectives; - } - break; + if (!(fieldName in projectCreationSchema.shape)) { + // Field not in schema, skip validation + return; } - validationErrors.set({ ...errors }); + // 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; + } + }); } diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 9d636f53..e52c3eb7 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -1,12 +1,11 @@ import { liveQuery } from "dexie"; import { writable, readable, get, derived } from "svelte/store"; -import { validateCreateForm, validationErrors } from "./draftValidation"; import { session } from "../../auth/store"; import { db } from "../../utils/drafts/db"; import { draftRepo } from "../../utils/drafts/repository"; -import type { Project, ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../../openapi/client"; +import type { Budget, ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto, ProjectReward } from "../../openapi/client"; export interface WizardConfiguration { projectDeadline: "minimum" | "optimum"; // Default: minimum @@ -28,6 +27,9 @@ export type Wizard = { // Step navigation currentStep: number; + // Total budget amount + budget?: Budget; + // Step 1: Configuration configuration: WizardConfiguration; @@ -54,7 +56,7 @@ export interface Draft { draftId: string; userId: number; - createProject: Project; + createProject: ProjectProjectCreationDto; wizardForm: Wizard; updatedAt: number; @@ -100,14 +102,14 @@ export function createDraftStore(userId: number) { }); } -export async function createDraft(project?: Project) { +export async function createDraft(project?: ProjectProjectCreationDto) { const draftId = createDraftId(); const userId = getUserId(); const draft: Draft = { draftId, userId, - createProject: project ?? ({} as Partial as Project), + createProject: project ?? ({} as Partial as ProjectProjectCreationDto), wizardForm: { currentStep: 1, configuration: { @@ -218,7 +220,7 @@ export function updateWizard(data: Partial) { * * @param data - Partial object with Project API Type data that has been modified */ -export function updateProject(data: Partial) { +export function updateProject(data: Partial) { currentDraft.update((draft) => { if (!draft) return draft; @@ -233,9 +235,7 @@ export function updateProject(data: Partial) { return updated; }); - if (validateCreateForm()) { - persistDraft(); - } else return; + persistDraft(); } export async function deleteDraft(draftId: string, userId: number) { From eb2ce1031155dc8fb1679994363a807e4c7e1f81 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Wed, 6 May 2026 10:05:05 +0200 Subject: [PATCH 014/113] fix: change drafts array store to derived being now directly dependant of session store state --- src/stores/drafts/projectDraft.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index e52c3eb7..acfc83ba 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -62,7 +62,18 @@ export interface Draft { updatedAt: number; } -export const drafts = readable(createDraftStore(getUserId())); +export const drafts = derived(session, ($session, set) => { + if (!$session?.user?.id) { + set([]); + return; + } + + const store = createDraftStore($session.user.id); + + const unsubscribe = store.subscribe(set); + + return unsubscribe; +}); export const currentDraft = writable(null); @@ -178,10 +189,14 @@ function persistDraft() { if (!draft) return; try { - await draftRepo.update(draft.draftId, draft.userId, { + const updatedDraft = { ...draft, updatedAt: Date.now(), - }); + }; + + currentDraft.set(updatedDraft); + + await draftRepo.update(draft.draftId, draft.userId, updatedDraft); } catch (error) { console.error("Failed to persist draft:", error); } @@ -246,3 +261,8 @@ export async function deleteDraft(draftId: string, userId: number) { currentDraft.set(null); } } + +export function resetCurrentDraftState() { + currentDraft.set(null); + touchedFields.set(new Set()); +} From c14d68a552799a78651841765891d532bcc8a722 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 15:32:48 +0200 Subject: [PATCH 015/113] fix: VideoUrlInput component removing all video embedding logic (API is already doing it) + modify types for onInput in DateInput component + add onInput prop and event in onBlur prop in TextInput component --- src/components/library/DateInput.svelte | 4 +- src/components/library/TextInput.svelte | 5 +- .../project/edit/VideoUrlInput.svelte | 175 +++--------------- 3 files changed, 33 insertions(+), 151 deletions(-) diff --git a/src/components/library/DateInput.svelte b/src/components/library/DateInput.svelte index 93003aae..2d0f217f 100644 --- a/src/components/library/DateInput.svelte +++ b/src/components/library/DateInput.svelte @@ -28,7 +28,7 @@ helperText?: string; error?: string; onBlur?: () => void; - onInput?: (date: Date) => void; + onInput?: (date: string) => void; } = $props(); const generatedId = $props.id(); @@ -84,7 +84,7 @@ // Call optional input handler if (onInput) { - onInput(dateValue); + onInput(dateToString(dateValue)); } } diff --git a/src/components/library/TextInput.svelte b/src/components/library/TextInput.svelte index c7baf4c1..4c0d8b92 100644 --- a/src/components/library/TextInput.svelte +++ b/src/components/library/TextInput.svelte @@ -13,6 +13,7 @@ labelText = undefined, helperText = undefined, error = undefined, + onInput = undefined, onBlur = undefined, }: { value?: string | undefined; @@ -26,7 +27,8 @@ labelText?: string; helperText?: string; error?: string; - onBlur?: () => void; + onInput?: (event: Event) => void; + onBlur?: (event?: FocusEvent) => void; } = $props(); const generatedId = $props.id(); @@ -46,6 +48,7 @@ bind:value id={finalId} onblur={onBlur} + oninput={onInput} {name} {type} {required} 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}
- From b2f9e8c8e3696d4e38e6b9d30add2538a07960f0 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 17:09:01 +0200 Subject: [PATCH 016/113] fix: add updateProject function call and other details in CreateProjectForm component --- .../create/CreateProjectForm.svelte | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/pages/[...locale]/create/CreateProjectForm.svelte b/src/pages/[...locale]/create/CreateProjectForm.svelte index 921e1487..f5f2d248 100644 --- a/src/pages/[...locale]/create/CreateProjectForm.svelte +++ b/src/pages/[...locale]/create/CreateProjectForm.svelte @@ -19,12 +19,15 @@ validationErrors, } from "../../../stores/drafts/draftValidation"; import { + createDraft, currentDraft, markFieldAsTouched, touchedFields, + updateProject, } from "../../../stores/drafts/projectDraft"; import { categories } from "../../../utils/categories"; import { formatCurrency } from "../../../utils/currencies"; + import { goto } from "../../../utils/navigation"; const categoriesOptions = categories.map((categories) => { return { id: categories.id, text: $t(categories.translationKey) }; @@ -69,9 +72,8 @@ fieldName: K, value: ProjectProjectCreationDto[K], ) { - if (!$currentDraft) return; // Update the draft value with proper typing - $currentDraft.createProject[fieldName] = value; + updateProject({ [fieldName]: value }); // Only validate on change if field has been touched if ($touchedFields.has(fieldName) || submitted) { @@ -121,6 +123,11 @@ return; } + if (!$currentDraft) { + apiError = "Draft not initialized"; + return; + } + // Submit to API isSubmitting = true; @@ -131,10 +138,6 @@ // Need to update the form to collect the correct fields or map them appropriately // TODO: Implement actual API submission when form fields match API requirements - if (!$currentDraft) { - apiError = "Draft data is missing. Please refresh the page and try again."; - return; - } const { data, error } = await apiProjectsPost({ body: { @@ -154,19 +157,27 @@ $validationErrors[field] = violation.message || "Invalid value"; } }); + return; } else { apiError = "An unexpected error occurred. Please try again."; + return; } } else if (data) { submitSuccess = true; + updateProject({ + title: $currentDraft.createProject.title, + subtitle: $currentDraft.createProject.subtitle, + categories: $currentDraft.createProject.categories as Category[], + release: $currentDraft.createProject.release, + }); + currentDraft.update((d) => (d ? { ...d, status: "project-created" } : d)); + // Redirect to project page + goto(`/project/${data.id}/edit`); } } catch (err) { // Handle unexpected errors apiError = "An unexpected error occurred. Please try again."; - if (import.meta.env.DEV) { - console.error("Unexpected error:", err); - } } finally { isSubmitting = false; } @@ -185,8 +196,14 @@ } $effect(() => { - console.log("Current draft state:", $currentDraft); - console.log("Current validation errors:", $validationErrors); + if (!$currentDraft) { + createDraft({ + title: "", + subtitle: "", + categories: [], + release: undefined, + }); + } }); @@ -247,6 +264,7 @@ bind:value={createProjectDraft.title} error={shouldShowError("title") ? $t($validationErrors.title) : undefined} onBlur={() => handleFieldBlur("title")} + onInput={(e) => handleFieldChange("title", (e.target as HTMLInputElement).value)} />
@@ -297,7 +317,7 @@ min={getMinDate()} error={shouldShowError("release") ? $t($validationErrors.release) : undefined} onBlur={() => handleFieldBlur("release")} - onInput={(date) => handleFieldChange("release", date.toDateString())} + onInput={(date) => handleFieldChange("release", date)} />
{#if apiError} From 0c0c540994afa90868656bcc58f96e18318013fc Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 17:11:25 +0200 Subject: [PATCH 017/113] feat: migrate wizard main components to IndexedDB from localStorage --- .../[idOrSlug]/edit/ProjectEditor.svelte | 81 ++++++++++--------- .../[idOrSlug]/edit/ProjectEditorShell.svelte | 23 ++---- 2 files changed, 51 insertions(+), 53 deletions(-) diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte index e3bad112..c5283e54 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte @@ -17,16 +17,18 @@ apiProjectCollaborationsPost, apiProjectRewardsPost, apiProjectsIdPatch, + type Category, type Project, } from "../../../../../openapi/client"; import { apiProjectsGetCollectionUrl } from "../../../../../openapi/client/paths.gen"; - import { - wizardState, - initializeFromProject, - clearLocalStorage, - } from "../../../../../stores/wizard-state"; import type { Session } from "../../../../../auth/types"; + import { + currentDraft, + deleteCurrentDraft, + updateProject, + updateWizard, + } from "../../../../../stores/drafts/projectDraft"; let { project, @@ -52,14 +54,19 @@ } // Initialize from project - initializeFromProject(project); + updateProject({ + title: project.title || "", + subtitle: project.subtitle || "", + categories: project.categories as Category[], + release: project.calendar?.release ?? undefined, + }); + updateWizard({ + currentStep: 1, // Start at step 1 by default + }); // Set the step from URL parameter if present if (initialStep !== 1) { - wizardState.update((state) => ({ - ...state, - currentStep: initialStep, - })); + updateWizard({ currentStep: initialStep }); } // Listen for browser back/forward navigation (client-side only) @@ -70,10 +77,7 @@ if (stepParam) { const step = parseInt(stepParam, 10); if (!isNaN(step) && step >= 1 && step <= 6) { - wizardState.update((state) => ({ - ...state, - currentStep: step, - })); + updateWizard({ currentStep: step }); } } }; @@ -87,23 +91,26 @@ }); // Reactive current step - const currentStep = $derived($wizardState.currentStep); + const currentStep = $derived($currentDraft?.wizardForm.currentStep ?? 1); let saveState = $state<"idle" | "saving" | "saved">("idle"); let errorMessage = $state(""); /** * Handle save draft to API */ - async function handleSave() { + async function handleSaveToApi() { saveState = "saving"; try { - const currentData = get(wizardState); + const draft = get(currentDraft); + if (!draft) return; + + const { createProject, wizardForm } = draft; const projectIri = apiProjectsGetCollectionUrl + "/" + (project.slug ? project.slug : project.id); - if (currentData.rewards.length > 0) { - currentData.rewards.forEach(async (reward) => { + if (wizardForm.rewards.length > 0) { + wizardForm.rewards.forEach(async (reward) => { const { error: rewardErr } = await apiProjectRewardsPost({ body: { project: projectIri, @@ -122,8 +129,8 @@ }); } - if (currentData.collaborations.length > 0) { - currentData.collaborations.forEach(async (collab) => { + if (wizardForm.collaborations.length > 0) { + wizardForm.collaborations.forEach(async (collab) => { const { error: collaborationErr } = await apiProjectCollaborationsPost({ body: { project: projectIri, @@ -137,8 +144,8 @@ }); } - if (currentData.budgetItems.minimum.length > 0) { - currentData.budgetItems.minimum.forEach(async (item) => { + if (wizardForm.budgetItems.minimum.length > 0) { + wizardForm.budgetItems.minimum.forEach(async (item) => { const { error: budgetItemErr } = await apiProjectBudgetItemsPost({ body: { project: projectIri, @@ -157,8 +164,8 @@ }); } - if (currentData.budgetItems.optimum.length > 0) { - currentData.budgetItems.optimum.forEach(async (item) => { + if (wizardForm.budgetItems.optimum.length > 0) { + wizardForm.budgetItems.optimum.forEach(async (item) => { const { error: budgetItemErr } = await apiProjectBudgetItemsPost({ body: { project: projectIri, @@ -180,15 +187,15 @@ const { error: projectErr } = await apiProjectsIdPatch({ path: { id: String(project.id) }, body: { - title: currentData.title, - subtitle: currentData.subtitle, - video: currentData.campaignInfo.video, + title: createProject.title, + subtitle: createProject.subtitle, + video: wizardForm.campaignInfo.video, description: - currentData.campaignInfo.objectives + - currentData.campaignInfo.legacy + - currentData.campaignInfo.targetAudience + - currentData.campaignInfo.team, - deadline: currentData.configuration.projectDeadline, + wizardForm.campaignInfo.objectives + + wizardForm.campaignInfo.legacy + + wizardForm.campaignInfo.targetAudience + + wizardForm.campaignInfo.team, + deadline: wizardForm.configuration.projectDeadline, }, headers: session.token.asHttpHeaders, }); @@ -208,9 +215,11 @@ */ async function handlePublish() { // In Phase 1, this is disabled until all steps are complete and sent to API - clearLocalStorage(); + if (!$currentDraft) return; + + deleteCurrentDraft($currentDraft.draftId, $currentDraft.userId); window.location.href = - "/project/" + (project.slug ? project.slug : project.id) + "/publish"; + "/project/" + (project.slug ?? project.id) + "/publish"; } @@ -218,7 +227,7 @@ {errorMessage} {saveState} {project} - onSave={handleSave} + onSave={handleSaveToApi} onPublish={handlePublish} > {@const StepComponent = getStepComponent(currentStep)} diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte index 971fda17..73108c2f 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte @@ -22,16 +22,11 @@ type Tab, } from "../../../../../components/library/TabNavigation.svelte"; import { t } from "../../../../../i18n/store"; - import { - wizardState, - navigateToStep, - persistenceError, - isReadyToPublish, - } from "../../../../../stores/wizard-state"; import EditIcon from "../../../../../svgs/EditIcon.svelte"; import type { Project } from "../../../../../openapi/client"; import type { Snippet } from "svelte"; + import { currentDraft, isReadyToPublish, navigateToStep, persistenceError, updateProject } from "../../../../../stores/drafts/projectDraft"; let { project, @@ -60,30 +55,24 @@ ]); // Reactive values from store - const currentStep = $derived($wizardState.currentStep); + const currentStep = $derived($currentDraft?.wizardForm.currentStep ?? 1); // Reactive derived values for title and subtitle - const title = $derived($wizardState.title); - const subtitle = $derived($wizardState.subtitle); + const title = $derived($currentDraft?.createProject.title); + const subtitle = $derived($currentDraft?.createProject.subtitle); /** * Handle title change */ function handleTitleChange(newTitle: string) { - wizardState.update((state) => ({ - ...state, - title: newTitle, - })); + updateProject({ title: newTitle }); } /** * Handle subtitle change */ function handleSubtitleChange(newSubtitle: string) { - wizardState.update((state) => ({ - ...state, - subtitle: newSubtitle, - })); + updateProject({ subtitle: newSubtitle }); } /** From ba1a6ac8f3d40ed5fbd449bea57755b70991bb1c Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 17:13:42 +0200 Subject: [PATCH 018/113] feat: add persistenceError, hasUnsavedChanges and isReadyToPublish stores + add status to Draft interface + add navigateToStep and updateUrl functions + modify updateWizard and add updateCampaign info functions in projectDraft file --- src/stores/drafts/projectDraft.ts | 149 ++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 9 deletions(-) diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index acfc83ba..bacdc8be 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -7,12 +7,23 @@ 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 + // Media/** images: MediaImage[]; video: string | undefined; @@ -60,6 +71,7 @@ export interface Draft { wizardForm: Wizard; updatedAt: number; + status: "initial" | "project-created" | "to-review" | "editing"; } export const drafts = derived(session, ($session, set) => { @@ -68,7 +80,7 @@ export const drafts = derived(session, ($session, set) => { return; } - const store = createDraftStore($session.user.id); + const store = setDraftsStore($session.user.id); const unsubscribe = store.subscribe(set); @@ -80,8 +92,28 @@ export const currentDraft = writable(null); export const wizard = derived(currentDraft, ($d) => $d?.wizardForm); export const project = derived(currentDraft, ($d) => $d?.createProject); +/** + * 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); + export function createDraftId() { return crypto.randomUUID(); } @@ -96,7 +128,7 @@ function getUserId(): number { return s.user.id; } -export function createDraftStore(userId: number) { +export function setDraftsStore(userId: number) { return readable([], (set) => { const subscription = liveQuery(() => db.drafts @@ -142,6 +174,7 @@ export async function createDraft(project?: ProjectProjectCreationDto) { }, }, updatedAt: Date.now(), + status: "initial", }; await draftRepo.create(draft); @@ -184,6 +217,7 @@ let saveTimer: ReturnType | null = null; function persistDraft() { if (saveTimer) clearTimeout(saveTimer); + hasUnsavedChanges.set(true); saveTimer = setTimeout(async () => { const draft = get(currentDraft); if (!draft) return; @@ -197,8 +231,21 @@ function persistDraft() { currentDraft.set(updatedDraft); await draftRepo.update(draft.draftId, draft.userId, updatedDraft); + + hasUnsavedChanges.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 hasUnsavedChanges as true when persistence fails + hasUnsavedChanges.set(true); } }, 1000); } @@ -218,7 +265,23 @@ export function updateWizard(data: Partial) { ...draft, wizardForm: { ...draft.wizardForm, + ...data, + + configuration: { + ...draft.wizardForm.configuration, + ...data.configuration, + }, + + campaignInfo: { + ...draft.wizardForm.campaignInfo, + ...data.campaignInfo, + }, + + budgetItems: { + ...draft.wizardForm.budgetItems, + ...data.budgetItems, + }, }, }; @@ -228,6 +291,20 @@ export function updateWizard(data: Partial) { persistDraft(); } +export function updateCampaignInfo( + data: Partial, +) { + const draft = get(currentDraft); + if (!draft) return; + + updateWizard({ + campaignInfo: { + ...draft.wizardForm.campaignInfo, + ...data + } + }); +} + /** * Update create Project form data * @@ -253,16 +330,70 @@ export function updateProject(data: Partial) { persistDraft(); } -export async function deleteDraft(draftId: string, userId: number) { - await draftRepo.delete(draftId, userId); - +export async function deleteCurrentDraft(draftId: string, userId: number) { const current = get(currentDraft); + if (current?.draftId === draftId) { currentDraft.set(null); } + + await draftRepo.delete(draftId, userId); + touchedFields.set(new Set()); + hasUnsavedChanges.set(false); + persistenceError.set(null); } -export function resetCurrentDraftState() { - currentDraft.set(null); - touchedFields.set(new Set()); +/** + * 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}`); + hasUnsavedChanges.set(true); + 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); + } } From 5a2118cebcbfe858b914710b978bb29d10de8929 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 17:14:58 +0200 Subject: [PATCH 019/113] feat: migrate CampaingInfo wizard step from localStorage to IndexedDB --- .../project/edit/CampaignInfoStep.svelte | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/components/project/edit/CampaignInfoStep.svelte b/src/components/project/edit/CampaignInfoStep.svelte index 01cae441..0fda3ee0 100644 --- a/src/components/project/edit/CampaignInfoStep.svelte +++ b/src/components/project/edit/CampaignInfoStep.svelte @@ -18,16 +18,15 @@ import RichTextEditor from "./RichTextEditor.svelte"; import VideoUrlInput from "./VideoUrlInput.svelte"; import { t } from "../../../i18n/store"; - import { - wizardState, - updateCampaignInfo, - navigateToStep, - validateCampaignInfo, - type MediaImage, - type VideoEmbed, - } from "../../../stores/wizard-state"; import Button from "../../library/Button.svelte"; import Grid from "../../library/Grid.svelte"; + import { + currentDraft, + navigateToStep, + updateCampaignInfo, + type MediaImage, + } from "../../../stores/drafts/projectDraft"; + import { validateCampaignInfo } from "../../../stores/drafts/draftValidation"; interface CampaignInfoStepProps { onContinue?: () => void; @@ -36,14 +35,22 @@ 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 +70,7 @@ * Handle image upload */ function handleImageUpload(image: MediaImage) { + if (!campaignInfo) return; updateCampaignInfo({ images: [...campaignInfo.images, image], }); @@ -80,29 +88,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, + }); } /** From 594ac50a9ebc7776e877371fa26c9934c12ef1da Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 19:26:57 +0200 Subject: [PATCH 020/113] fix: modify hashes using murmurhash3 properly --- src/stores/drafts/draftValidation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 714a1401..1a281be0 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -199,7 +199,7 @@ export function validateCampaignInfo(wizard: Wizard): ValidationErrors { export function validateReward(reward: ProjectReward): ValidationErrors { const errors: ValidationErrors = {}; - const hash = murmur(JSON.stringify(reward)); + const hash = murmur.murmur3(JSON.stringify(reward)); if (!reward.title.trim()) { errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title"; @@ -218,7 +218,7 @@ export function validateReward(reward: ProjectReward): ValidationErrors { export function validateCollaboration(collab: ProjectCollaboration): ValidationErrors { const errors: ValidationErrors = {}; - const hash = murmur(JSON.stringify(collab)); + const hash = murmur.murmur3(JSON.stringify(collab)); if (!collab.title.trim()) { errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title"; @@ -239,7 +239,7 @@ export function validateCollaboration(collab: ProjectCollaboration): ValidationE export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors { const errors: ValidationErrors = {}; - const hash = murmur(JSON.stringify(item)); + const hash = murmur.murmur3(JSON.stringify(item)); if (!item.title.trim()) { errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title."; From 088a1182d06aac2ed814783bbdba9798f3a9f80c Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 19:28:15 +0200 Subject: [PATCH 021/113] feat: full migration from localStorage to IndexedDB in Configuration, Rewards, Collaborations and BudgetItems wizard steps --- .../project/edit/AdminBudgetCard.svelte | 8 +- .../project/edit/BudgetModal.svelte | 2 +- src/components/project/edit/BudgetStep.svelte | 26 ++- .../project/edit/CollaborationsStep.svelte | 19 +- .../project/edit/CollabsCard.svelte | 17 +- .../project/edit/CollabsModal.svelte | 14 +- .../project/edit/ConfigurationStep.svelte | 15 +- .../project/edit/RewardsCard.svelte | 17 +- .../project/edit/RewardsModal.svelte | 13 +- .../project/edit/RewardsStep.svelte | 15 +- .../[idOrSlug]/edit/ProjectEditor.svelte | 11 +- src/stores/drafts/projectDraft.ts | 206 ++++++++++++++++++ 12 files changed, 293 insertions(+), 70 deletions(-) diff --git a/src/components/project/edit/AdminBudgetCard.svelte b/src/components/project/edit/AdminBudgetCard.svelte index 529777cf..aca1cc10 100644 --- a/src/components/project/edit/AdminBudgetCard.svelte +++ b/src/components/project/edit/AdminBudgetCard.svelte @@ -2,16 +2,12 @@ import BudgetModal from "./BudgetModal.svelte"; import CreateCard from "./CreateCard.svelte"; import { t } from "../../../i18n/store"; - import { - addBudgetItem, - deleteBudgetItem, - updateBudgetItem, - validationErrors, - } from "../../../stores/wizard-state"; import { formatCurrency } from "../../../utils/currencies"; import Button from "../../library/Button.svelte"; import type { ProjectBudgetItem } from "../../../openapi/client"; + import { addBudgetItem, deleteBudgetItem, updateBudgetItem } from "../../../stores/drafts/projectDraft"; + import { validationErrors } from "../../../stores/drafts/draftValidation"; let { item, diff --git a/src/components/project/edit/BudgetModal.svelte b/src/components/project/edit/BudgetModal.svelte index e802f278..c25eb92f 100644 --- a/src/components/project/edit/BudgetModal.svelte +++ b/src/components/project/edit/BudgetModal.svelte @@ -3,13 +3,13 @@ import DeleteModal from "./DeleteModal.svelte"; import { t } from "../../../i18n/store"; - import { validationErrors } from "../../../stores/wizard-state"; import { defaultCurrency } from "../../../utils/currencies"; import Button from "../../library/Button.svelte"; import Toast from "../../library/Toast.svelte"; import type { ProjectBudgetItem } from "../../../openapi/client"; import type { ClassNameValue } from "tailwind-merge"; + import { validationErrors } from "../../../stores/drafts/draftValidation"; let { open = $bindable(false), diff --git a/src/components/project/edit/BudgetStep.svelte b/src/components/project/edit/BudgetStep.svelte index 2fd7f028..77b82b9c 100644 --- a/src/components/project/edit/BudgetStep.svelte +++ b/src/components/project/edit/BudgetStep.svelte @@ -2,17 +2,13 @@ import AdminBudgetCard from "./AdminBudgetCard.svelte"; import { t } from "../../../i18n/store"; import { type Project, type ProjectBudgetItem } from "../../../openapi/client"; - import { - navigateToStep, - validateBudgetAmount, - validationErrors, - wizardState, - } from "../../../stores/wizard-state"; import { formatCurrency } from "../../../utils/currencies"; import Button from "../../library/Button.svelte"; import Grid from "../../library/Grid.svelte"; import Toast from "../../library/Toast.svelte"; import LoadingSpinner from "../../search/LoadingSpinner.svelte"; + import { currentDraft, navigateToStep } from "../../../stores/drafts/projectDraft"; + import { validateBudgetAmount, validationErrors } from "../../../stores/drafts/draftValidation"; let { project, @@ -20,8 +16,12 @@ project: Project; } = $props(); - let minBudgetItems: ProjectBudgetItem[] = $state($wizardState.budgetItems.minimum); - let optBudgetItems: ProjectBudgetItem[] = $state($wizardState.budgetItems.optimum); + let minBudgetItems: ProjectBudgetItem[] = $state( + $currentDraft?.wizardForm.budgetItems.minimum || [], + ); + let optBudgetItems: ProjectBudgetItem[] = $state( + $currentDraft?.wizardForm.budgetItems.optimum || [], + ); let loading = $state(false); let showErrorToast = $state(false); @@ -30,7 +30,9 @@ * Simple navigation to next step (6) - validation happens on save/submit */ function handleContinue() { - const errors = validateBudgetAmount(); + if (!$currentDraft) return; + + const errors = validateBudgetAmount($currentDraft); if (Object.keys(errors).length > 0) { validationErrors.set(errors); @@ -43,8 +45,8 @@ async function loadBudgetItems() { loading = true; - minBudgetItems = $wizardState.budgetItems.minimum; - optBudgetItems = $wizardState.budgetItems.optimum; + minBudgetItems = $currentDraft?.wizardForm.budgetItems.minimum || []; + optBudgetItems = $currentDraft?.wizardForm.budgetItems.optimum || []; loading = false; } @@ -52,7 +54,7 @@ $effect(() => { if (Object.keys($validationErrors).length > 0) console.log("Errores de validación:", $validationErrors); - if ($wizardState) { + if ($currentDraft) { loadBudgetItems(); } }); diff --git a/src/components/project/edit/CollaborationsStep.svelte b/src/components/project/edit/CollaborationsStep.svelte index 5cda3c5d..80c30535 100644 --- a/src/components/project/edit/CollaborationsStep.svelte +++ b/src/components/project/edit/CollaborationsStep.svelte @@ -1,16 +1,15 @@ @@ -49,10 +48,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..db125dec 100644 --- a/src/components/project/edit/CollabsCard.svelte +++ b/src/components/project/edit/CollabsCard.svelte @@ -2,23 +2,25 @@ import CollabsModal from "./CollabsModal.svelte"; import CreateCard from "./CreateCard.svelte"; import { t } from "../../../i18n/store"; + import { renderMarkdown } from "../../../utils/renderMarkdown"; + import Button from "../../library/Button.svelte"; + import type { Project, ProjectCollaboration } from "../../../openapi/client"; import { addCollaboration, deleteCollaboration, updateCollaboration, - validationErrors, - type WizardCollaboration, - } from "../../../stores/wizard-state"; - import { renderMarkdown } from "../../../utils/renderMarkdown"; - import Button from "../../library/Button.svelte"; + } from "../../../stores/drafts/projectDraft"; + import { validationErrors } from "../../../stores/drafts/draftValidation"; 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 +29,7 @@ let openModal = $state(false); let showModalErrorToast = $state(false); - function handleSaveCollab(data: WizardCollaboration | null) { + function handleSaveCollab(data: ProjectCollaboration | null) { if (!data) return; let errors; @@ -95,6 +97,7 @@ 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 ffe1299a..a47f6695 100644 --- a/src/components/project/edit/ConfigurationStep.svelte +++ b/src/components/project/edit/ConfigurationStep.svelte @@ -11,8 +11,12 @@ @@ -42,10 +45,10 @@ {:else} {#each rewards as reward, index} - + {/each} - + {/if} diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte index c5283e54..a1c7ca1a 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte @@ -20,7 +20,6 @@ type Category, type Project, } from "../../../../../openapi/client"; - import { apiProjectsGetCollectionUrl } from "../../../../../openapi/client/paths.gen"; import type { Session } from "../../../../../auth/types"; import { @@ -106,14 +105,12 @@ if (!draft) return; const { createProject, wizardForm } = draft; - const projectIri = - apiProjectsGetCollectionUrl + "/" + (project.slug ? project.slug : project.id); if (wizardForm.rewards.length > 0) { wizardForm.rewards.forEach(async (reward) => { const { error: rewardErr } = await apiProjectRewardsPost({ body: { - project: projectIri, + project: reward.project, title: reward.title, description: reward.description, money: { @@ -133,7 +130,7 @@ wizardForm.collaborations.forEach(async (collab) => { const { error: collaborationErr } = await apiProjectCollaborationsPost({ body: { - project: projectIri, + project: collab.project, title: collab.title, description: collab.description, isFulfilled: false, @@ -148,7 +145,7 @@ wizardForm.budgetItems.minimum.forEach(async (item) => { const { error: budgetItemErr } = await apiProjectBudgetItemsPost({ body: { - project: projectIri, + project: item.project, type: item.type, title: item.title, description: item.description, @@ -168,7 +165,7 @@ wizardForm.budgetItems.optimum.forEach(async (item) => { const { error: budgetItemErr } = await apiProjectBudgetItemsPost({ body: { - project: projectIri, + project: item.project, type: item.type, title: item.title, description: item.description, diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index bacdc8be..f4944c92 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -6,6 +6,7 @@ import { db } from "../../utils/drafts/db"; import { draftRepo } from "../../utils/drafts/repository"; import type { Budget, ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto, ProjectReward } from "../../openapi/client"; +import { validateBudgetItem, validateCollaboration, validateReward } from "./draftValidation"; /** * Media image data @@ -291,6 +292,18 @@ export function updateWizard(data: Partial) { persistDraft(); } +export function updateConfiguration(data: Partial) { + const draft = get(currentDraft); + if (!draft) return; + + updateWizard({ + configuration: { + ...draft.wizardForm.configuration, + ...data + } + }); +} + export function updateCampaignInfo( data: Partial, ) { @@ -305,6 +318,199 @@ export function updateCampaignInfo( }); } +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] = 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] = 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, +) { + const errors = validateBudgetItem(item); + + if (Object.keys(errors).length > 0) { + return errors; + } + + const draft = get(currentDraft); + if (!draft) return {}; + + const updated = [ + ...draft.wizardForm.budgetItems[item.deadline], + ]; + + updated[index] = item; + + updateWizard({ + budgetItems: { + ...draft.wizardForm.budgetItems, + + [item.deadline]: updated, + }, + }); + + 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), + }, + }); +} + /** * Update create Project form data * From 28ce3046949a79a730c6b0e6c9f637a125b52224 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 20:37:48 +0200 Subject: [PATCH 022/113] feat: modify ProjectEditor component to manage the API calls and validation logic in other helper files, making it so easier for the component to call only one function (publishDraft) --- .../[idOrSlug]/edit/ProjectEditor.svelte | 138 +++--------------- .../[idOrSlug]/edit/ProjectEditorShell.svelte | 10 +- src/stores/drafts/draftValidation.ts | 45 ++++-- src/utils/projectPublisher.ts | 77 ++++++++++ src/utils/projectSubmissionApi.ts | 58 ++++++++ 5 files changed, 194 insertions(+), 134 deletions(-) create mode 100644 src/utils/projectPublisher.ts create mode 100644 src/utils/projectSubmissionApi.ts diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte index a1c7ca1a..7814c434 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditor.svelte @@ -13,10 +13,6 @@ import ProjectEditorShell from "./ProjectEditorShell.svelte"; import { getStepComponent } from "./steps"; import { - apiProjectBudgetItemsPost, - apiProjectCollaborationsPost, - apiProjectRewardsPost, - apiProjectsIdPatch, type Category, type Project, } from "../../../../../openapi/client"; @@ -25,9 +21,12 @@ import { currentDraft, deleteCurrentDraft, + persistDraft, updateProject, updateWizard, } from "../../../../../stores/drafts/projectDraft"; + import { publishDraft } from "../../../../../utils/projectPublisher"; + import { onMount } from "svelte"; let { project, @@ -37,8 +36,7 @@ session: Session; } = $props(); - // Initialize wizard state from project and set up URL sync - $effect(() => { + onMount(() => { // Read URL parameter first (before initializing) let initialStep = 1; if (typeof window !== "undefined") { @@ -94,129 +92,27 @@ let saveState = $state<"idle" | "saving" | "saved">("idle"); let errorMessage = $state(""); - /** - * Handle save draft to API - */ - async function handleSaveToApi() { + async function handlePublish() { + const draft = get(currentDraft); + + if (!draft) return; + saveState = "saving"; try { - const draft = get(currentDraft); - if (!draft) return; + await publishDraft(draft, session, String(project.id)); - const { createProject, wizardForm } = draft; + await deleteCurrentDraft(draft.draftId, draft.userId); - if (wizardForm.rewards.length > 0) { - wizardForm.rewards.forEach(async (reward) => { - const { error: rewardErr } = await apiProjectRewardsPost({ - body: { - project: reward.project, - title: reward.title, - description: reward.description, - money: { - amount: reward.money.amount, - currency: reward.money.currency, - }, - isFinite: reward.isFinite, - unitsTotal: reward.unitsTotal, - }, - headers: session.token.asHttpHeaders, - }); - if (rewardErr) throw rewardErr; - }); - } + window.location.href = "/project/" + (project.slug ?? project.id) + "/publish"; + } catch (err) { + errorMessage = err instanceof Error ? err.message : "Unknown error"; - if (wizardForm.collaborations.length > 0) { - wizardForm.collaborations.forEach(async (collab) => { - const { error: collaborationErr } = await apiProjectCollaborationsPost({ - body: { - project: collab.project, - title: collab.title, - description: collab.description, - isFulfilled: false, - }, - headers: session.token.asHttpHeaders, - }); - if (collaborationErr) throw collaborationErr; - }); - } - - if (wizardForm.budgetItems.minimum.length > 0) { - wizardForm.budgetItems.minimum.forEach(async (item) => { - const { error: budgetItemErr } = await apiProjectBudgetItemsPost({ - body: { - project: item.project, - type: item.type, - title: item.title, - description: item.description, - money: { - amount: item.money.amount, - currency: item.money.currency, - }, - deadline: item.deadline, - }, - headers: session.token.asHttpHeaders, - }); - if (budgetItemErr) throw budgetItemErr; - }); - } - - if (wizardForm.budgetItems.optimum.length > 0) { - wizardForm.budgetItems.optimum.forEach(async (item) => { - const { error: budgetItemErr } = await apiProjectBudgetItemsPost({ - body: { - project: item.project, - type: item.type, - title: item.title, - description: item.description, - money: { - amount: item.money.amount, - currency: item.money.currency, - }, - deadline: item.deadline, - }, - headers: session.token.asHttpHeaders, - }); - if (budgetItemErr) throw budgetItemErr; - }); - } - - const { error: projectErr } = await apiProjectsIdPatch({ - path: { id: String(project.id) }, - body: { - title: createProject.title, - subtitle: createProject.subtitle, - video: wizardForm.campaignInfo.video, - description: - wizardForm.campaignInfo.objectives + - wizardForm.campaignInfo.legacy + - wizardForm.campaignInfo.targetAudience + - wizardForm.campaignInfo.team, - deadline: wizardForm.configuration.projectDeadline, - }, - headers: session.token.asHttpHeaders, - }); - - if (projectErr) throw projectErr; - } catch (err: any) { - errorMessage = err; saveState = "idle"; - } finally { - errorMessage = ""; - saveState = "saved"; + return; } - } - /** - * Handle publish - */ - async function handlePublish() { - // In Phase 1, this is disabled until all steps are complete and sent to API - if (!$currentDraft) return; - - deleteCurrentDraft($currentDraft.draftId, $currentDraft.userId); - window.location.href = - "/project/" + (project.slug ?? project.id) + "/publish"; + saveState = "saved"; } @@ -224,7 +120,7 @@ {errorMessage} {saveState} {project} - onSave={handleSaveToApi} + onSave={persistDraft} onPublish={handlePublish} > {@const StepComponent = getStepComponent(currentStep)} diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte index 73108c2f..00674143 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte @@ -26,7 +26,13 @@ import type { Project } from "../../../../../openapi/client"; import type { Snippet } from "svelte"; - import { currentDraft, isReadyToPublish, navigateToStep, persistenceError, updateProject } from "../../../../../stores/drafts/projectDraft"; + import { + currentDraft, + isReadyToPublish, + navigateToStep, + persistenceError, + updateProject, + } from "../../../../../stores/drafts/projectDraft"; let { project, @@ -38,7 +44,7 @@ }: { project: Project; children: Snippet; - onSave: () => Promise; + onSave: () => void; onPublish?: () => void; saveState: "idle" | "saving" | "saved"; errorMessage: string; diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 1a281be0..82d0344c 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -58,13 +58,36 @@ export function validateCreateForm(): boolean { } } -export function validateDraftToPublish(draft: Draft): ValidationErrors { +export function validateDraftToPublish( + draft: Draft, +): ValidationErrors { const wizard = draft.wizardForm; - return { - ...validateConfiguration(wizard), - ...validateCampaignInfo(wizard), - }; + const errors: ValidationErrors = {}; + + 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 item of wizard.budgetItems.minimum) { + Object.assign(errors, validateBudgetItem(item)); + } + + for (const item of wizard.budgetItems.optimum) { + Object.assign(errors, validateBudgetItem(item)); + } + + Object.assign(errors, validateBudgetAmount(draft)); + + return errors; } export function stripHtml(html: string): string { @@ -80,7 +103,7 @@ export function stripHtml(html: string): string { * Validation schema for Configuration step */ export const configurationSchema = z.object({ - projectDeadline: z.union([z.literal(1), z.literal(2)]), + projectDeadline: z.enum(["minimum", "optimum"]), }); /** @@ -278,11 +301,11 @@ export function validateBudgetAmount(draft: Draft) { 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; - } + const minimumItemsTotalAmount = + budgetItems.minimum.reduce( + (acc, item) => acc + item.money.amount, + 0, + ); if (minimumItemsTotalAmount !== budget?.minimum?.money?.amount) { errors.minimum_length = "pages.project.edit.budget.validation.amountMinimum"; diff --git a/src/utils/projectPublisher.ts b/src/utils/projectPublisher.ts new file mode 100644 index 00000000..24c4b606 --- /dev/null +++ b/src/utils/projectPublisher.ts @@ -0,0 +1,77 @@ +import { + createReward, + createCollaboration, + createBudgetItem, + updateProject, +} from "./projectSubmissionApi"; + +import type { Session } from "../auth/types"; +import type { Draft } from "../stores/drafts/projectDraft"; +import { validateDraftToPublish } from "../stores/drafts/draftValidation"; + +export class PublishValidationError extends Error { + constructor( + public errors: Record, + ) { + super("Draft validation failed"); + } +} + +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; + + await Promise.all([ + ...wizard.rewards.map((reward) => + createReward(reward, session), + ), + + ...wizard.collaborations.map((collab) => + createCollaboration(collab, session), + ), + + ...wizard.budgetItems.minimum.map((item) => + createBudgetItem(item, session), + ), + + ...wizard.budgetItems.optimum.map((item) => + createBudgetItem(item, session), + ), + ]); + + const description = [ + wizard.campaignInfo.objectives, + wizard.campaignInfo.legacy, + wizard.campaignInfo.targetAudience, + wizard.campaignInfo.team, + ] + .filter(Boolean) + .join("\n\n"); + + const result = await updateProject( + projectId, + { + title: draft.createProject.title, + subtitle: draft.createProject.subtitle, + video: wizard.campaignInfo.video, + description, + deadline: wizard.configuration.projectDeadline, + }, + session, + ); + + if (result.error) { + throw result.error; + } + + return result.data; +} diff --git a/src/utils/projectSubmissionApi.ts b/src/utils/projectSubmissionApi.ts new file mode 100644 index 00000000..d8fc9b4e --- /dev/null +++ b/src/utils/projectSubmissionApi.ts @@ -0,0 +1,58 @@ +import { + apiProjectBudgetItemsPost, + apiProjectCollaborationsPost, + apiProjectRewardsPost, + apiProjectsIdPatch, +} from "../openapi/client"; + +import type { + ProjectReward, + ProjectCollaboration, + ProjectBudgetItem, +} from "../openapi/client"; +import type { Session } from "../auth/types"; + +export async function updateProject( + projectId: string, + body: any, + 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 createCollaboration( + collab: ProjectCollaboration, + session: Session, +) { + return apiProjectCollaborationsPost({ + body: { + ...collab, + isFulfilled: false, + }, + headers: session.token.asHttpHeaders, + }); +} + +export async function createBudgetItem( + item: ProjectBudgetItem, + session: Session, +) { + return apiProjectBudgetItemsPost({ + body: item, + headers: session.token.asHttpHeaders, + }); +} From c487f9b38d5a5d3508a95e2ac2f5a0254ecc18ad Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 20:38:56 +0200 Subject: [PATCH 023/113] fix: make isReadyToPublish a derived store from currentDraft store --- src/stores/drafts/projectDraft.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index f4944c92..58002981 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -6,7 +6,7 @@ import { db } from "../../utils/drafts/db"; import { draftRepo } from "../../utils/drafts/repository"; import type { Budget, ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto, ProjectReward } from "../../openapi/client"; -import { validateBudgetItem, validateCollaboration, validateReward } from "./draftValidation"; +import { validateBudgetItem, validateCollaboration, validateDraftToPublish, validateReward } from "./draftValidation"; /** * Media image data @@ -113,7 +113,16 @@ 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); +export const isReadyToPublish = derived( + currentDraft, + ($draft) => { + if (!$draft) return false; + + const errors = validateDraftToPublish($draft); + + return Object.keys(errors).length === 0; + }, +); export function createDraftId() { return crypto.randomUUID(); @@ -215,7 +224,7 @@ export function markFieldAsTouched(fieldName: string) { let saveTimer: ReturnType | null = null; -function persistDraft() { +export function persistDraft() { if (saveTimer) clearTimeout(saveTimer); hasUnsavedChanges.set(true); From 13cfae817c0ec9b24a9474fd5c994d64b7e3c376 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 7 May 2026 20:43:08 +0200 Subject: [PATCH 024/113] fix: delete old localStorage logic --- src/pages/[...locale]/create/project-draft.ts | 146 --- src/stores/wizard-state.ts | 1001 ----------------- 2 files changed, 1147 deletions(-) delete mode 100644 src/pages/[...locale]/create/project-draft.ts delete mode 100644 src/stores/wizard-state.ts diff --git a/src/pages/[...locale]/create/project-draft.ts b/src/pages/[...locale]/create/project-draft.ts deleted file mode 100644 index 06503711..00000000 --- a/src/pages/[...locale]/create/project-draft.ts +++ /dev/null @@ -1,146 +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; -} - -// 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(), -}); - -/** - * 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; - - return hasTitle && hasSubtitle && hasCategories; -}); - -/** - * 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(), - }); - validationErrors.set({}); - touchedFields.set(new Set()); -} 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; -} From b5c11e7888e0815b8a18c6b42bf11799683ab096 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Fri, 8 May 2026 10:46:32 +0200 Subject: [PATCH 025/113] fix: change some old remaining wizard-state imports --- src/components/project/edit/GeoSelector.svelte | 7 ++----- src/components/project/edit/LanguageSelector.svelte | 7 ++----- src/components/project/edit/MediaUploader.svelte | 2 +- src/components/project/edit/OwnerInfoStep.svelte | 2 +- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/project/edit/GeoSelector.svelte b/src/components/project/edit/GeoSelector.svelte index 9a518dd4..1175fd55 100644 --- a/src/components/project/edit/GeoSelector.svelte +++ b/src/components/project/edit/GeoSelector.svelte @@ -14,11 +14,8 @@ --> {@const StepComponent = getStepComponent(currentStep)} - + From 7bc678815641ab228d68162dc4b66aba7ea407b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Wed, 13 May 2026 14:18:22 +0200 Subject: [PATCH 039/113] fix: add default pb-20 to App main layout --- src/components/App.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/App.svelte b/src/components/App.svelte index 475b5900..835ed0bd 100644 --- a/src/components/App.svelte +++ b/src/components/App.svelte @@ -31,7 +31,7 @@
From 497b4f5967c660c5f75baba511e55d7444bf9682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Wed, 13 May 2026 14:19:14 +0200 Subject: [PATCH 040/113] feat: move detectSocialPlatform into ProfileInfo, accept raw links --- src/components/profile/ProfileInfo.svelte | 97 +++++++++++------------ 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/src/components/profile/ProfileInfo.svelte b/src/components/profile/ProfileInfo.svelte index e2de250e..139c817a 100644 --- a/src/components/profile/ProfileInfo.svelte +++ b/src/components/profile/ProfileInfo.svelte @@ -7,72 +7,65 @@ import XIcon from "../../svgs/XIcon.svelte"; import MapIcon from "../icons/Location.svelte"; + import type { Link } from "../../openapi/client/types.gen"; + interface Props { displayName: string; location?: string; - socialLinks?: { - twitter?: string; - instagram?: string; - facebook?: string; - linkedin?: string; - email?: string; - medium?: string; - website?: string; - }; + links?: Link[]; + email?: string; } - let { displayName, location, socialLinks = {} }: Props = $props(); + let { displayName, location, links = [], email }: Props = $props(); + + type SocialLinkKey = "email" | "facebook" | "instagram" | "linkedin" | "medium" | "twitter"; interface SocialLink { - name: string; url: string; label: string; icon: any; } - const allSocialLinks: SocialLink[] = [ - { - name: "twitter", - url: socialLinks.twitter || "", - label: "X/Twitter", - icon: XIcon, - }, - { - name: "instagram", - url: socialLinks.instagram || "", - label: "Instagram", - icon: InstagramIcon, - }, - { - name: "facebook", - url: socialLinks.facebook || "", - label: "Facebook", - icon: FacebookIcon, - }, - { - name: "linkedin", - url: socialLinks.linkedin || "", - label: "LinkedIn", - icon: LinkedinIcon, - }, - { - name: "gmail", - url: socialLinks.email || "", - label: "Email", - icon: GmailIcon, - }, - { - name: "medium", - url: socialLinks.medium || "", - label: "Medium", - icon: MediumIcon, - }, - ]; + function detectSocialPlatform(link: Link): SocialLinkKey | null { + const url = (link.url ?? "").toLowerCase(); + const rel = (link.rel ?? "").toLowerCase(); + if (rel === "twitter" || url.includes("twitter.com") || url.includes("x.com")) + return "twitter"; + if (rel === "instagram" || url.includes("instagram.com")) return "instagram"; + if (rel === "facebook" || url.includes("facebook.com")) return "facebook"; + if (rel === "linkedin" || url.includes("linkedin.com")) return "linkedin"; + if (rel === "medium" || url.includes("medium.com")) return "medium"; + return null; + } - const socialMediaLinks = $derived(allSocialLinks.filter((link) => link.url)); + const resolvedLinks = $derived( + links.reduce>( + (acc, link) => { + const platform = detectSocialPlatform(link); + if (platform && link.url && !acc[platform]) acc[platform] = link.url; + return acc; + }, + email ? { email: `mailto:${email}` } : {}, + ), + ); + + const allSocialLinks: Record = $derived({ + email: { url: resolvedLinks.email || "", label: "Email", icon: GmailIcon }, + facebook: { url: resolvedLinks.facebook || "", label: "Facebook", icon: FacebookIcon }, + instagram: { url: resolvedLinks.instagram || "", label: "Instagram", icon: InstagramIcon }, + linkedin: { url: resolvedLinks.linkedin || "", label: "LinkedIn", icon: LinkedinIcon }, + medium: { url: resolvedLinks.medium || "", label: "Medium", icon: MediumIcon }, + twitter: { url: resolvedLinks.twitter || "", label: "X/Twitter", icon: XIcon }, + }); + + const socialMediaLinks = $derived( + (Object.entries(allSocialLinks) as [SocialLinkKey, SocialLink][]).filter( + ([, link]) => link.url, + ), + ); -
+

{displayName} @@ -91,7 +84,7 @@ {#if socialMediaLinks.length > 0}
- {#each socialMediaLinks as link} + {#each socialMediaLinks as [, link]} Date: Wed, 13 May 2026 14:19:54 +0200 Subject: [PATCH 041/113] feat: add public user profile page --- .../[...locale]/user/[idOrHandle]/User.svelte | 142 ++++++++++++++++++ .../[...locale]/user/[idOrHandle]/index.astro | 102 +++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 src/pages/[...locale]/user/[idOrHandle]/User.svelte create mode 100644 src/pages/[...locale]/user/[idOrHandle]/index.astro diff --git a/src/pages/[...locale]/user/[idOrHandle]/User.svelte b/src/pages/[...locale]/user/[idOrHandle]/User.svelte new file mode 100644 index 00000000..416c1e81 --- /dev/null +++ b/src/pages/[...locale]/user/[idOrHandle]/User.svelte @@ -0,0 +1,142 @@ + + +
+ +
+ + +
+ + +
+ + +
+
+ {displayName} +
+ +
+
+
+
+ + + + + + +
+ +
+ + +
+ {#if bio} + {#await renderMarkdown(bio)} +

+ {:then html} +
+ {@html html} +
+ {/await} + {:else} +

+ Todavía no hay información sobre este perfil. +

+ {/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..5a9e6131 --- /dev/null +++ b/src/pages/[...locale]/user/[idOrHandle]/index.astro @@ -0,0 +1,102 @@ +--- +import User from "./User.svelte"; +import Layout from "../../../../layouts/Layout.astro"; +import App from "../../../../components/App.svelte"; +import { + apiUsersIdOrHandleGet, + apiProjectSupportsGetCollection, + apiProjectSupportsmoneyTotalGetCollection, +} from "../../../../openapi/client"; +import type { Accounting, Money, ProjectSupport } from "../../../../openapi/client"; +import type { Campaign } from "../../../../types/campaign"; +import { client } from "../../../../openapi/client/client.gen"; +import { getLanguage } from "../../../../middleware/utils"; +import { NotFound } from "../../../../utils/responses"; + +const { idOrHandle } = Astro.params; +const { lang, session } = Astro.locals; + +if (!idOrHandle) { + return NotFound; +} + +const { data: user, error } = await apiUsersIdOrHandleGet({ + path: { idOrHandle }, +}); + +if (error || !user) { + return NotFound; +} + +let userSupports: ProjectSupport[] = []; +let totalDonated = { amount: 0, currency: "EUR" }; +let projectsDonatedCount = 0; + +if (user.accounting) { + try { + const { data: supports } = await apiProjectSupportsGetCollection({ + query: { origin: user.accounting }, + }); + + if (supports) { + userSupports = supports; + projectsDonatedCount = supports.length; + + 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 projectIRIs = userSupports + .slice(0, 10) + .map((s) => s.project) + .filter(Boolean); +let campaigns: Campaign[] = []; + +if (projectIRIs.length > 0) { + campaigns = await Promise.all( + projectIRIs.map(async (projectIRI) => { + try { + const { data: project } = (await client.get({ + url: client.buildUrl({ url: projectIRI! }), + headers: { "Accept-Language": getLanguage(Astro) }, + })) as { data: any }; + + 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, + } as Campaign; + } catch (e) { + console.error(`Error fetching project ${projectIRI}:`, e); + return null; + } + }), + ).then((results) => results.filter(Boolean) as Campaign[]); +} +--- + + + + + + From c0b337b816b064efda14cbf7634c4027639e5261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Wed, 13 May 2026 14:34:58 +0200 Subject: [PATCH 042/113] chore: remove legacy profile page, superseded by user/[idOrHandle] --- .../[...locale]/profile/[idOrHandle].astro | 232 ------------------ 1 file changed, 232 deletions(-) delete mode 100644 src/pages/[...locale]/profile/[idOrHandle].astro diff --git a/src/pages/[...locale]/profile/[idOrHandle].astro b/src/pages/[...locale]/profile/[idOrHandle].astro deleted file mode 100644 index 12146e67..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 ? ( -
-

{errorMessage}

-
- ) : ( -
- {/* Hero Section with Cover and Avatar */} - - - {/* User Info Section */} - - - {/* Tabs Navigation */} -
- -
- - {/* Tab Content - About */} -
- {bio && ( -
-
-
- )} -
- - {/* Tab Content - Projects */} -
- {campaigns.length > 0 ? ( -
- -
- ) : ( -
-

{t("profile.noProjects")}

-
- )} -
- - {/* Tab Content - Donor Type */} - -
- ) - } - - From 8482d17231baffb5b48fe0fe0ad674da6c80e896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Wed, 13 May 2026 14:35:28 +0200 Subject: [PATCH 043/113] feat: implement donor type tab and clean up unused props --- .../[...locale]/user/[idOrHandle]/User.svelte | 24 +++++++------------ .../[...locale]/user/[idOrHandle]/index.astro | 9 ++++++- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/pages/[...locale]/user/[idOrHandle]/User.svelte b/src/pages/[...locale]/user/[idOrHandle]/User.svelte index 416c1e81..2b7c283e 100644 --- a/src/pages/[...locale]/user/[idOrHandle]/User.svelte +++ b/src/pages/[...locale]/user/[idOrHandle]/User.svelte @@ -3,7 +3,7 @@ import ShareIcon from "../../../../components/icons/Share.svelte"; import Button from "../../../../components/library/Button.svelte"; import Grid from "../../../../components/library/Grid.svelte"; - // import ProfileDonorType from "../../../../components/profile/ProfileDonorType.svelte"; + import ProfileDonorType from "../../../../components/profile/ProfileDonorType.svelte"; import ProfileInfo from "../../../../components/profile/ProfileInfo.svelte"; import Tabs from "../../../../components/Tabs.svelte"; import { t } from "../../../../i18n/store"; @@ -16,17 +16,17 @@ interface Props { user: User; campaigns?: Campaign[]; - // projectsDonated?: number; - // moneyDonatedAmount?: number; - // moneyDonatedCurrency?: string; + projectsDonated?: number; + moneyDonatedAmount?: number; + moneyDonatedCurrency?: string; } let { user, campaigns = [], - // projectsDonated = 0, - // moneyDonatedAmount = 0, - // moneyDonatedCurrency = "EUR", + projectsDonated = 0, + moneyDonatedAmount = 0, + moneyDonatedCurrency = "EUR", }: Props = $props(); const DEFAULT_COVER = "https://placehold.co/4000x2700/D43111/FFFFFF?text=+&font=raleway"; @@ -38,7 +38,7 @@ const tabs = $derived([ { id: "about", label: $t("profile.tabs.about") }, { id: "projects", label: $t("profile.tabs.projects") }, - //{ id: "donorType", label: $t("profile.tabs.donorType") }, + { id: "donorType", label: $t("profile.tabs.donorType") }, ]); // TODO: replace with user.bio when the API adds it @@ -124,13 +124,6 @@
- -
diff --git a/src/pages/[...locale]/user/[idOrHandle]/index.astro b/src/pages/[...locale]/user/[idOrHandle]/index.astro index 5a9e6131..79a67199 100644 --- a/src/pages/[...locale]/user/[idOrHandle]/index.astro +++ b/src/pages/[...locale]/user/[idOrHandle]/index.astro @@ -97,6 +97,13 @@ if (projectIRIs.length > 0) { - + From 806f5d695a319eabd5729b59292f6cb6a8db9603 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 14 May 2026 10:15:16 +0200 Subject: [PATCH 044/113] refactor: improve publish validation architecture, avoid store mutations from publish validation, and add publishErrors persistent derived store from currentDraft (isReadyToPublish now works with it) --- .../create/CreateProjectForm.svelte | 4 +- .../[idOrSlug]/edit/ProjectEditorShell.svelte | 6 +- src/stores/drafts/draftValidation.ts | 93 +++++++++++-------- src/stores/drafts/projectDraft.ts | 12 +-- 4 files changed, 64 insertions(+), 51 deletions(-) diff --git a/src/pages/[...locale]/create/CreateProjectForm.svelte b/src/pages/[...locale]/create/CreateProjectForm.svelte index d791affe..e16e7284 100644 --- a/src/pages/[...locale]/create/CreateProjectForm.svelte +++ b/src/pages/[...locale]/create/CreateProjectForm.svelte @@ -95,8 +95,10 @@ submitted = true; apiError = null; + const errors = validateCreateForm($project); + // Validate entire form - const isValid = validateCreateForm($project); + const isValid = Object.keys(errors).length === 0; if (!isValid) { // Scroll to error summary diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte index 00674143..50561503 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte @@ -91,10 +91,10 @@ } /** - * Handle Save localStorage Draft to API button + * Handle Save IndexedDB Draft to API button */ - async function handleSave() { - await onSave(); + function handleSave() { + onSave(); } /** diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 36ed8b4b..3052fe72 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -2,7 +2,7 @@ import murmur from "murmurhash-js"; import { derived, get, writable } from "svelte/store"; import z from "zod"; -import type { Draft, Wizard } from "./projectDraft"; +import { currentDraft, type Draft, type Wizard } from "./projectDraft"; import { projectCreationSchema } from "../../pages/[...locale]/create/validation"; import type { ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto, ProjectReward } from "../../openapi/client"; @@ -11,6 +11,16 @@ import type { ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto export type ValidationErrors = Record; export const validationErrors = writable({}); + +export const publishErrors = derived(currentDraft, ($draft) => { + if (!$draft) { + return {}; + } + + const errors = validateDraftToPublish(get(currentDraft) as Draft); + return errors; +}); + export const isDraftValid = derived(validationErrors, ($errors) => { return Object.keys($errors).length === 0; }); @@ -19,23 +29,27 @@ export const isDraftValid = derived(validationErrors, ($errors) => { * Validates the entire create project form and updates the validation errors store. * Returns true if the form is valid, false otherwise. */ -export function validateCreateForm(createProjectDraft: ProjectProjectCreationDto): boolean { - const result = projectCreationSchema.safeParse(createProjectDraft); +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; } }); - validationErrors.set(errors); - return false; - } else { - validationErrors.set({}); - return true; + + return errors; } + + return {}; } export function validateDraftToPublish( @@ -45,6 +59,8 @@ export function validateDraftToPublish( const errors: ValidationErrors = {}; + Object.assign(errors, validateCreateForm(draft.createProject)); + Object.assign(errors, validateConfiguration(wizard)); Object.assign(errors, validateCampaignInfo(wizard)); @@ -57,12 +73,12 @@ export function validateDraftToPublish( Object.assign(errors, validateCollaboration(collab)); } - for (const item of wizard.budgetItems.minimum) { - Object.assign(errors, validateBudgetItem(item)); + for (const minimumItem of wizard.budgetItems.minimum) { + Object.assign(errors, validateBudgetItem(minimumItem)); } - for (const item of wizard.budgetItems.optimum) { - Object.assign(errors, validateBudgetItem(item)); + for (const optimumItem of wizard.budgetItems.optimum) { + Object.assign(errors, validateBudgetItem(optimumItem)); } Object.assign(errors, validateBudgetAmount(draft)); @@ -92,33 +108,30 @@ export const configurationSchema = z.object({ * 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(); - * } - * } + * @returns Record of field errors (empty if valid) */ -export function validateConfiguration(wizard: Wizard): boolean { - const result = configurationSchema.safeParse(wizard.configuration); +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; } }); - validationErrors.set(errors); - return false; + + return errors; } - validationErrors.set({}); - return true; + return {}; } /** @@ -148,53 +161,53 @@ export function validateCampaignInfo(wizard: Wizard): ValidationErrors { // Media validation if (data.images.length === 0 && !data.video) { - errors.media = "pages.project.edit.rewards.validationn_info.reward.media.required"; + 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.validationn_info.reward.objectives.required"; + "pages.project.edit.rewards.validation_info.reward.objectives.required"; } else if (objectivesPlainText.length < 50) { errors.objectives = - "pages.project.edit.rewards.validationn_info.reward.objectives.min_length"; + "pages.project.edit.rewards.validation_info.reward.objectives.min_length"; } else if (objectivesPlainText.length > 5000) { errors.objectives = - "pages.project.edit.rewards.validationn_info.reward.objectives.max_length"; + "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.validationn_info.reward.legacy.required"; + errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.required"; } else if (legacyPlainText.length < 50) { - errors.legacy = "pages.project.edit.rewards.validationn_info.reward.legacy.min_length"; + errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.min_length"; } else if (legacyPlainText.length > 5000) { - errors.legacy = "pages.project.edit.rewards.validationn_info.reward.legacy.max_length"; + 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.validationn_info.reward.target.required"; + "pages.project.edit.rewards.validation_info.reward.target.required"; } else if (targetPlainText.length < 30) { errors.targetAudience = - "pages.project.edit.rewards.validationn_info.reward.target.min_length"; + "pages.project.edit.rewards.validation_info.reward.target.min_length"; } else if (targetPlainText.length > 5000) { errors.targetAudience = - "pages.project.edit.rewards.validationn_info.reward.target.max_length"; + "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.validationn_info.reward.team.required"; + errors.team = "pages.project.edit.rewards.validation_info.reward.team.required"; } else if (teamPlainText.length < 50) { - errors.team = "pages.project.edit.rewards.validationn_info.reward.team.min_length"; + errors.team = "pages.project.edit.rewards.validation_info.reward.team.min_length"; } else if (teamPlainText.length > 5000) { - errors.team = "pages.project.edit.rewards.validationn_info.reward.team.max_length"; + errors.team = "pages.project.edit.rewards.validation_info.reward.team.max_length"; } return errors; diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 686da9b9..9856776c 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -6,7 +6,7 @@ import { db } from "../../utils/drafts/db"; import { draftRepo } from "../../utils/drafts/repository"; import type { Budget, ProjectBudgetItem, ProjectCollaboration, ProjectProjectCreationDto, ProjectReward } from "../../openapi/client"; -import { validateBudgetItem, validateCollaboration, validateDraftToPublish, validateReward, validationErrors } from "./draftValidation"; +import { publishErrors, validateBudgetItem, validateCollaboration, validateDraftToPublish, validateReward, validationErrors } from "./draftValidation"; /** * Media image data @@ -134,16 +134,14 @@ 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 + * Used to enable/disable the Publish button in the wizard UI */ export const isReadyToPublish = derived( - currentDraft, - ($draft) => { + [publishErrors, currentDraft], + ([$errors, $draft]) => { if (!$draft) return false; - const errors = validateDraftToPublish($draft); - - return Object.keys(errors).length === 0; + return Object.keys($errors).length === 0; }, ); From 5547b99bb7b564f78ae3185dc4d2a6971916923c Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 14 May 2026 11:47:03 +0200 Subject: [PATCH 045/113] refactor: add budget key for draft in CreateProjectForm --- src/stores/drafts/projectDraft.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/stores/drafts/projectDraft.ts b/src/stores/drafts/projectDraft.ts index 686da9b9..3877d10d 100644 --- a/src/stores/drafts/projectDraft.ts +++ b/src/stores/drafts/projectDraft.ts @@ -64,11 +64,15 @@ export type Wizard = { // aboutYou: WizardAboutYou; } +export interface CreateProjectForm extends ProjectProjectCreationDto { + budget?: Budget; +} + export interface Draft { draftId: string; userId: number; - createProject: ProjectProjectCreationDto; + createProject: CreateProjectForm; wizardForm: Wizard; updatedAt: Date; @@ -205,7 +209,7 @@ export async function createDraft(project?: ProjectProjectCreationDto) { const draft: Draft = { draftId, userId, - createProject: project ?? ({} as Partial as ProjectProjectCreationDto), + createProject: project ?? ({} as Partial as CreateProjectForm), wizardForm: { currentStep: 1, configuration: { From cee49fd6425b17c9d566bdbb059cfbeb008b31ed Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 14 May 2026 11:58:01 +0200 Subject: [PATCH 046/113] fix: remove previous budget amount validation --- src/stores/drafts/draftValidation.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/stores/drafts/draftValidation.ts b/src/stores/drafts/draftValidation.ts index 36ed8b4b..9e713d1f 100644 --- a/src/stores/drafts/draftValidation.ts +++ b/src/stores/drafts/draftValidation.ts @@ -281,16 +281,6 @@ export function validateBudgetAmount(draft: Draft) { errors.minimum_length = "pages.project.edit.budget.validation.minimumItemsLength"; } - const minimumItemsTotalAmount = - budgetItems.minimum.reduce( - (acc, item) => acc + item.money.amount, - 0, - ); - - if (minimumItemsTotalAmount !== budget?.minimum?.money?.amount) { - errors.minimum_length = "pages.project.edit.budget.validation.amountMinimum"; - } - return errors; } From 31c42d3807c7845e17c6e4b5718bdd559aa8478e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Thu, 14 May 2026 12:08:13 +0200 Subject: [PATCH 047/113] feat: share action implemented in button --- src/pages/[...locale]/user/[idOrHandle]/User.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/[...locale]/user/[idOrHandle]/User.svelte b/src/pages/[...locale]/user/[idOrHandle]/User.svelte index 2b7c283e..5d076ca1 100644 --- a/src/pages/[...locale]/user/[idOrHandle]/User.svelte +++ b/src/pages/[...locale]/user/[idOrHandle]/User.svelte @@ -51,7 +51,7 @@
- From d544f27c6ad25061189f0e4911ae7452fa777c39 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Thu, 14 May 2026 13:25:03 +0200 Subject: [PATCH 048/113] fix: adjust several styles in CreateProjectForm and the components that it uses (matching to Figma) --- src/components/BaseCard.svelte | 5 +++-- src/components/library/Category.svelte | 4 ++-- .../create/CreateProjectForm.svelte | 19 ++++++++++++------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/BaseCard.svelte b/src/components/BaseCard.svelte index 97975336..e520778c 100644 --- a/src/components/BaseCard.svelte +++ b/src/components/BaseCard.svelte @@ -1,14 +1,15 @@
{@render children()}
diff --git a/src/components/library/Category.svelte b/src/components/library/Category.svelte index 9c014714..db9e72e5 100644 --- a/src/components/library/Category.svelte +++ b/src/components/library/Category.svelte @@ -4,7 +4,7 @@ import type { Snippet } from "svelte"; const styles = { - default: "inset-ring-1 hover:inset-ring-0 inset-ring-secondary bg-white", + default: "inset-ring-1 hover:inset-ring-0 inset-ring-secondary t bg-white text-secondary", active: "bg-secondary text-primary hover:text-secondary", ghost: "", }; @@ -27,7 +27,7 @@

- +

{$currentDraft?.createProject.title || $t("pages.project.create.description.titlePlaceholder")}

-

+

{$currentDraft?.createProject.subtitle || $t("pages.project.create.description.subtitlePlaceholder")}

From fb78b30ff2c8d6c7abd56841a4c9697f73e5d1e2 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Fri, 15 May 2026 20:27:04 +0200 Subject: [PATCH 049/113] fix: errors that derived stores were causing due to the derived stores rendering before it's origin deriving store --- .../project/edit/AdminBudgetCard.svelte | 8 ++- .../project/edit/BudgetModal.svelte | 2 +- src/components/project/edit/BudgetStep.svelte | 8 ++- .../project/edit/CollabsCard.svelte | 2 +- .../project/edit/CollabsModal.svelte | 2 +- .../project/edit/GeoSelector.svelte | 7 ++- .../project/edit/LanguageSelector.svelte | 7 ++- .../project/edit/RewardsCard.svelte | 8 ++- .../project/edit/RewardsModal.svelte | 2 +- .../create/CreateProjectForm.svelte | 33 ++++++++++- src/stores/drafts/draftValidation.ts | 58 ++++++------------- src/stores/drafts/projectDraft.ts | 54 +++++++++++++---- 12 files changed, 123 insertions(+), 68 deletions(-) diff --git a/src/components/project/edit/AdminBudgetCard.svelte b/src/components/project/edit/AdminBudgetCard.svelte index aca1cc10..b2b58743 100644 --- a/src/components/project/edit/AdminBudgetCard.svelte +++ b/src/components/project/edit/AdminBudgetCard.svelte @@ -6,8 +6,12 @@ import Button from "../../library/Button.svelte"; import type { ProjectBudgetItem } from "../../../openapi/client"; - import { addBudgetItem, deleteBudgetItem, updateBudgetItem } from "../../../stores/drafts/projectDraft"; - import { validationErrors } from "../../../stores/drafts/draftValidation"; + import { + addBudgetItem, + deleteBudgetItem, + updateBudgetItem, + validationErrors, + } from "../../../stores/drafts/projectDraft"; let { item, diff --git a/src/components/project/edit/BudgetModal.svelte b/src/components/project/edit/BudgetModal.svelte index c25eb92f..64538298 100644 --- a/src/components/project/edit/BudgetModal.svelte +++ b/src/components/project/edit/BudgetModal.svelte @@ -9,7 +9,7 @@ import type { ProjectBudgetItem } from "../../../openapi/client"; import type { ClassNameValue } from "tailwind-merge"; - import { validationErrors } from "../../../stores/drafts/draftValidation"; + import { validationErrors } from "../../../stores/drafts/projectDraft"; let { open = $bindable(false), diff --git a/src/components/project/edit/BudgetStep.svelte b/src/components/project/edit/BudgetStep.svelte index 77b82b9c..101eec56 100644 --- a/src/components/project/edit/BudgetStep.svelte +++ b/src/components/project/edit/BudgetStep.svelte @@ -7,8 +7,12 @@ import Grid from "../../library/Grid.svelte"; import Toast from "../../library/Toast.svelte"; import LoadingSpinner from "../../search/LoadingSpinner.svelte"; - import { currentDraft, navigateToStep } from "../../../stores/drafts/projectDraft"; - import { validateBudgetAmount, validationErrors } from "../../../stores/drafts/draftValidation"; + import { + currentDraft, + navigateToStep, + validationErrors, + } from "../../../stores/drafts/projectDraft"; + import { validateBudgetAmount } from "../../../stores/drafts/draftValidation"; let { project, diff --git a/src/components/project/edit/CollabsCard.svelte b/src/components/project/edit/CollabsCard.svelte index db125dec..4ad7f49d 100644 --- a/src/components/project/edit/CollabsCard.svelte +++ b/src/components/project/edit/CollabsCard.svelte @@ -9,8 +9,8 @@ addCollaboration, deleteCollaboration, updateCollaboration, + validationErrors } from "../../../stores/drafts/projectDraft"; - import { validationErrors } from "../../../stores/drafts/draftValidation"; let { project, diff --git a/src/components/project/edit/CollabsModal.svelte b/src/components/project/edit/CollabsModal.svelte index 3e440c16..d52e461e 100644 --- a/src/components/project/edit/CollabsModal.svelte +++ b/src/components/project/edit/CollabsModal.svelte @@ -8,7 +8,7 @@ import type { ClassNameValue } from "tailwind-merge"; import type { Project, ProjectCollaboration } from "../../../openapi/client"; - import { validationErrors } from "../../../stores/drafts/draftValidation"; + import { validationErrors } from "../../../stores/drafts/projectDraft"; import { apiProjectsGetCollectionUrl } from "../../../openapi/client/paths.gen"; let { diff --git a/src/components/project/edit/GeoSelector.svelte b/src/components/project/edit/GeoSelector.svelte index 1175fd55..f3a2371e 100644 --- a/src/components/project/edit/GeoSelector.svelte +++ b/src/components/project/edit/GeoSelector.svelte @@ -14,8 +14,11 @@ -->
{#if variant === "reward"} - + {:else if variant === "collab"} - + {:else if variant === "budget"} - + {/if} diff --git a/src/components/project/edit/RewardsCard.svelte b/src/components/project/edit/RewardsCard.svelte index c1d419de..72e3bf20 100644 --- a/src/components/project/edit/RewardsCard.svelte +++ b/src/components/project/edit/RewardsCard.svelte @@ -66,6 +66,7 @@ {#if isCreateCard} ("idle"); let errorMessage = $state(""); async function saveToAPI() { - const draft = get(currentDraft); - - if (!draft) return; - - saveState = "saving"; + if (!$currentDraft) return; try { - await publishDraft(draft, session, String(project.id)); + const currentSession = get(session); - deleteCurrentDraft(draft.draftId, draft.userId); + if (!currentSession) { + throw new Error("User session not found"); + } - window.location.href = "/project/" + (project.slug ?? project.id) + "/publish"; + await publishDraft($currentDraft, currentSession, String(project.id)); + + hasUnsavedChanges.set(false); } catch (err) { errorMessage = err instanceof Error ? err.message : "Unknown error"; - saveState = "idle"; return; } - - saveState = "saved"; } function handlePublish() { + if (!$currentDraft) return; + const idOrSlug = project.slug ?? project.id; + deleteCurrentDraft($currentDraft.draftId, $currentDraft.userId); window.location.href = `/project/${idOrSlug}/publish`; } - + {@const StepComponent = getStepComponent(currentStep)} diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte index 50561503..e1636003 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/ProjectEditorShell.svelte @@ -28,25 +28,26 @@ import type { Snippet } from "svelte"; import { currentDraft, + hasUnsavedChanges, isReadyToPublish, + isSavingDraft, navigateToStep, persistenceError, updateProject, } from "../../../../../stores/drafts/projectDraft"; + import ActionableButton from "../../../../../components/library/ActionableButton.svelte"; let { project, children, onSave, onPublish, - saveState = $bindable("idle"), errorMessage = $bindable(""), }: { project: Project; children: Snippet; onSave: () => void; onPublish?: () => void; - saveState: "idle" | "saving" | "saved"; errorMessage: string; } = $props(); @@ -93,12 +94,12 @@ /** * Handle Save IndexedDB Draft to API button */ - function handleSave() { - onSave(); + async function handleSave() { + await onSave(); } /** - * Handle Publish button + * Handle Publish button (redirects to publish page after saving) */ function handlePublish() { if (onPublish) { @@ -197,21 +198,20 @@ {$t("common.preview")} - + {/snippet} + {#snippet actionedChildren()} + {$t("common.saved")} + {/snippet} +

{$currentDraft?.createProject.title || From d18ac797e9634416801cd74429b9dba080e0b44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Mon, 18 May 2026 13:56:23 +0200 Subject: [PATCH 053/113] chore: add translation for "see more" --- src/i18n/locales/es.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fba1d040..34a80a24 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -486,7 +486,8 @@ "length": "anónimos", "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": { From e4dc6212683185a0edd3e0677eb53842a0c57949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Mon, 18 May 2026 13:58:10 +0200 Subject: [PATCH 054/113] feat: add load more pagination to project community tab --- .../project/ProjectCommunity.svelte | 71 ++++++++++++------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/src/components/project/ProjectCommunity.svelte b/src/components/project/ProjectCommunity.svelte index 6d082d0d..e33bd9e9 100644 --- a/src/components/project/ProjectCommunity.svelte +++ b/src/components/project/ProjectCommunity.svelte @@ -13,6 +13,7 @@ import Loader from "../../svgs/Loader.svelte"; import { formatCurrency } from "../../utils/currencies"; import { extractId } from "../../utils/extractId"; + import ActionableButton from "../library/ActionableButton.svelte"; import Grid from "../library/Grid.svelte"; import type { Accounting, Project, ProjectSupport } from "../../openapi/client/index"; @@ -25,26 +26,24 @@ accounting: Accounting; } = $props(); - const projectId = project.id!.toString(); + const projectId = $derived(project.id?.toString()); + const PAGE_SIZE = 30; - let projectsSupportItems = $state< - (ProjectSupport & { - displayName: string; - matchfunding: boolean; - })[] - >([]); + type EnrichedSupport = ProjectSupport & { + displayName: string; + matchfunding: boolean; + }; - let selectedProjectSupport: - | (ProjectSupport & { - displayName: string; - matchfunding: boolean; - }) - | null = $state(null); + let projectsSupportItems = $state([]); + + let selectedProjectSupport: EnrichedSupport | null = $state(null); let isLoaded = $state(false); let openModal = $state(false); + let hasMore = $state(false); + let currentPage = $state(1); - function getSupportType(item: (typeof projectsSupportItems)[number]) { + function getSupportType(item: EnrichedSupport) { switch (true) { case item.matchfunding: return "matchfunding"; @@ -64,29 +63,43 @@ ), ); - onMount(async () => { - const { data: publicSupports } = await apiProjectSupportsGetCollection({ - query: { project: projectId, anonymous: false }, + async function fetchPage(page: number): Promise { + const { data } = await apiProjectSupportsGetCollection({ + query: { project: projectId, anonymous: false, page }, }); - const supportsWithOwners = await Promise.all( - (publicSupports || []).map(async (support) => { + const items = (data as ProjectSupport[] | null) ?? []; + hasMore = items.length >= PAGE_SIZE; + return items; + } + + async function enrichSupports(supports: ProjectSupport[]): Promise { + return Promise.all( + supports.map(async (support) => { const id = extractId(support?.origin!); - const { data: user } = await apiUsersIdOrHandleGet({ path: { idOrHandle: id! } }); - const displayName = user?.displayName!; - return { ...support, - displayName, + displayName: user?.displayName!, matchfunding: support.origin?.includes("match")!, }; }), ); + } - projectsSupportItems = supportsWithOwners; + onMount(async () => { + const supports = await fetchPage(1); + projectsSupportItems = await enrichSupports(supports); isLoaded = true; }); + + async function loadMore() { + const nextPage = currentPage + 1; + const supports = await fetchPage(nextPage); + const enriched = await enrichSupports(supports); + projectsSupportItems = [...projectsSupportItems, ...enriched]; + currentPage = nextPage; + }
@@ -121,6 +134,14 @@ {/each} {/if} + + {#if hasMore} +
+ + {$t("pages.project.view.tabs.community.loadMore")} + +
+ {/if}
{/if}

@@ -128,7 +149,7 @@ {#if selectedProjectSupport} From 0cc7f55f4f1c05e8306b5274fa6296e34e29b354 Mon Sep 17 00:00:00 2001 From: Dayv1dDev Date: Mon, 18 May 2026 14:03:26 +0200 Subject: [PATCH 055/113] fix: replace Astro.redirect call with goto util function which can take a callback query param --- src/pages/[...locale]/project/[idOrSlug]/edit/index.astro | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/[...locale]/project/[idOrSlug]/edit/index.astro b/src/pages/[...locale]/project/[idOrSlug]/edit/index.astro index a01de2c0..52a08177 100644 --- a/src/pages/[...locale]/project/[idOrSlug]/edit/index.astro +++ b/src/pages/[...locale]/project/[idOrSlug]/edit/index.astro @@ -4,6 +4,7 @@ import { apiProjectsIdOrSlugGet, apiProjectsIdPatch } from "../../../../../opena import { getSession } from "../../../../../auth/session"; import App from "../../../../../components/App.svelte"; import ProjectEditor from "./ProjectEditor.svelte"; +import { goto } from "../../../../../utils/navigation"; const { t } = Astro.locals; const locale = Astro.locals.lang; @@ -16,9 +17,7 @@ if (!idOrSlug) { const session = await getSession(Astro.cookies); if (!session) { const requestUrl = new URL(Astro.request.url); - return Astro.redirect( - "/login?callback=" + encodeURIComponent(requestUrl.pathname + requestUrl.search), - ); + return goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } }); } const { data: project } = await apiProjectsIdOrSlugGet({ path: { idOrSlug } }); From 660c8261e4d52baa1b0907f1d6a51be4aa81bf5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Tue, 19 May 2026 14:05:50 +0200 Subject: [PATCH 056/113] feat: layout anonymous donations card within community grid based on matchfunding presence --- .../project/ProjectCommunity.svelte | 68 ++++++++++++------- .../project/ProjectCommunityAnonymous.svelte | 39 +++++------ .../ProjectCommunityMatchfunding.svelte | 2 +- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/src/components/project/ProjectCommunity.svelte b/src/components/project/ProjectCommunity.svelte index e33bd9e9..ebeb7323 100644 --- a/src/components/project/ProjectCommunity.svelte +++ b/src/components/project/ProjectCommunity.svelte @@ -43,19 +43,10 @@ let hasMore = $state(false); let currentPage = $state(1); - function getSupportType(item: EnrichedSupport) { - switch (true) { - case item.matchfunding: - return "matchfunding"; - default: - return "default"; - } - } - const groupedItems = $derived.by(() => projectsSupportItems.reduce( (groups, item) => { - const type = getSupportType(item); + const type = item.matchfunding ? "matchfunding" : "default"; (groups[type] ??= []).push(item); return groups; }, @@ -63,13 +54,13 @@ ), ); - async function fetchPage(page: number): Promise { + async function fetchPage(page: number, size: number): Promise { const { data } = await apiProjectSupportsGetCollection({ - query: { project: projectId, anonymous: false, page }, + query: { project: projectId, anonymous: false, page, itemsPerPage: size }, }); const items = (data as ProjectSupport[] | null) ?? []; - hasMore = items.length >= PAGE_SIZE; + hasMore = items.length >= size; return items; } @@ -87,15 +78,28 @@ ); } + const matchfundingCount = $derived(groupedItems.matchfunding?.length ?? 0); + const hasMatchfunding = $derived(matchfundingCount > 0); + const isSingleMatchfunding = $derived(matchfundingCount === 1); + + const visibleDefaultItems = $derived( + hasMatchfunding + ? groupedItems.default + : groupedItems.default?.slice( + 0, + PAGE_SIZE - 2 + Math.max(0, (currentPage - 1) * PAGE_SIZE), + ), + ); + onMount(async () => { - const supports = await fetchPage(1); + const supports = await fetchPage(1, PAGE_SIZE); projectsSupportItems = await enrichSupports(supports); isLoaded = true; }); async function loadMore() { const nextPage = currentPage + 1; - const supports = await fetchPage(nextPage); + const supports = await fetchPage(nextPage, PAGE_SIZE); const enriched = await enrichSupports(supports); projectsSupportItems = [...projectsSupportItems, ...enriched]; currentPage = nextPage; @@ -112,20 +116,32 @@ {$t("pages.project.view.tabs.community.content.title")}

- - {#each groupedItems.matchfunding as item (item.id)} - - {/each} - - + {#if hasMatchfunding} + + {#each groupedItems.matchfunding as item (item.id)} + + {/each} + + + {/if} {#if groupedItems.default?.length} - {#each groupedItems.default as item (item.id)} + {#if !hasMatchfunding} +
+ +
+ {/if} + {#each visibleDefaultItems as item (item.id)}
-
+
Community donation
-
-
- {$t("pages.project.view.tabs.community.matchfunding.contribution")} -
-
- - {length} - {$t("pages.project.view.tabs.community.anonymous.length")} - -
-
- - {formatCurrency(money, currency)} - -
+
+
+
+ + {length} + {$t("pages.project.view.tabs.community.anonymous.length")} + + {formatCurrency(money, currency)}
-
- {$t("pages.project.view.tabs.community.anonymous.title")} +
+
+ {$t("pages.project.view.tabs.community.anonymous.title")} +
+

+ {$t("pages.project.view.tabs.community.anonymous.description")} +

-

- {$t("pages.project.view.tabs.community.anonymous.description")} -

diff --git a/src/components/project/ProjectCommunityMatchfunding.svelte b/src/components/project/ProjectCommunityMatchfunding.svelte index f5267f85..9843ff88 100644 --- a/src/components/project/ProjectCommunityMatchfunding.svelte +++ b/src/components/project/ProjectCommunityMatchfunding.svelte @@ -6,7 +6,7 @@ {/each} diff --git a/src/components/Admin/Slider.svelte b/src/components/Admin/Slider.svelte index 0c2cd478..e9ef520a 100644 --- a/src/components/Admin/Slider.svelte +++ b/src/components/Admin/Slider.svelte @@ -78,7 +78,7 @@ class="main-carousel h-full first:ml-0 opacity-{isSliderLoaded && !$isLoading ? 100 : 0}" > {#each slides as { title, amount }} - + {/each}
diff --git a/src/components/Admin/Table.svelte b/src/components/Admin/Table.svelte index 43e7350f..62273bc6 100644 --- a/src/components/Admin/Table.svelte +++ b/src/components/Admin/Table.svelte @@ -224,7 +224,7 @@ handleAmountChange(+e.currentTarget.value)} - placeholder={$t("checkout.tipjar.input")} + placeholder={$t("pages.checkout.tipjar.input")} disabled={!isChecked} class:border-red-500={hasError} class:ring-red-200={hasError} @@ -98,7 +98,7 @@ /> {#if hasError}

- {$t("checkout.tipjar.error.invalidAmount")} + {$t("pages.checkout.tipjar.error.invalidAmount")}

{/if}
@@ -113,7 +113,7 @@ onchange={(e) => toggleTip()} />
diff --git a/src/components/Checkout/WalletConfirmation.astro b/src/components/Checkout/WalletConfirmation.astro index cbfb4de3..c7eea332 100644 --- a/src/components/Checkout/WalletConfirmation.astro +++ b/src/components/Checkout/WalletConfirmation.astro @@ -25,7 +25,7 @@ const defaultCurrency = import.meta.env.PUBLIC_DEFAULT_CURRENCY; class="bg-primary text-secondary cursor-pointer rounded-3xl px-6 py-4 font-bold" type="submit" > - {t("payment.global.btn.continue")} + {t("common.continue")} diff --git a/src/components/Checkout/WalletInfo.svelte b/src/components/Checkout/WalletInfo.svelte index eb6bbd93..54ae9ee5 100644 --- a/src/components/Checkout/WalletInfo.svelte +++ b/src/components/Checkout/WalletInfo.svelte @@ -16,10 +16,9 @@
- {$t("payment.wallet-confirmation.currentBalance")}{$t("pages.checkout.wallet.currentBalance")} -

+

{formatCurrency(accounting.balance.amount, accounting.balance.currency)}

@@ -28,16 +27,16 @@ {$t("payment.wallet-confirmation.amountToUse")} -

+

{formatCurrency($total, defaultCurrency)}

{$t("payment.wallet-confirmation.remainingBalance")} + >{$t("pages.checkout.wallet.remainingBalance")} -

+

{formatCurrency(accounting.balance.amount - $total, accounting.balance.currency)}

diff --git a/src/components/CollapsibleBox.svelte b/src/components/CollapsibleBox.svelte index bd4ef561..efbfb99f 100644 --- a/src/components/CollapsibleBox.svelte +++ b/src/components/CollapsibleBox.svelte @@ -21,8 +21,8 @@ detailsId, isInitiallyCollapsed = false, showToggleButton = true, - buttonTextShow = $t("checkout.summary.show_details"), - buttonTextHide = $t("checkout.summary.hide_details"), + buttonTextShow = $t("pages.checkout.summary.showDetails"), + buttonTextHide = $t("pages.checkout.summary.hideDetails"), header, content, }: Props = $props(); diff --git a/src/components/Oauth.astro b/src/components/Oauth.astro index d7fd7161..d0147e54 100644 --- a/src/components/Oauth.astro +++ b/src/components/Oauth.astro @@ -17,7 +17,7 @@ const providers = [ providers.map(({ icon: Icon }) => (
diff --git a/src/components/ProjectTags.svelte b/src/components/ProjectTags.svelte index 792159b9..317c9156 100644 --- a/src/components/ProjectTags.svelte +++ b/src/components/ProjectTags.svelte @@ -5,15 +5,11 @@ import type { Project } from "../openapi/client"; export let project: Project; - - function parseCategoryIri(category: string) { - return category.split("/")[3]; - }
{#each project.categories as category} - + {/each}
diff --git a/src/components/RegisterForm.astro b/src/components/RegisterForm.astro index e2627a70..899b1d0b 100644 --- a/src/components/RegisterForm.astro +++ b/src/components/RegisterForm.astro @@ -41,7 +41,7 @@ const { t } = Astro.locals; /> @@ -59,7 +59,7 @@ const { t } = Astro.locals; /> @@ -77,7 +77,7 @@ const { t } = Astro.locals; /> @@ -95,7 +95,7 @@ const { t } = Astro.locals; /> @@ -130,7 +130,7 @@ const { t } = Astro.locals; /> @@ -150,7 +150,7 @@ const { t } = Astro.locals; /> @@ -168,7 +168,7 @@ const { t } = Astro.locals; /> diff --git a/src/components/RewardModal.svelte b/src/components/RewardModal.svelte index 875a64d0..5d2d1acb 100644 --- a/src/components/RewardModal.svelte +++ b/src/components/RewardModal.svelte @@ -74,7 +74,9 @@ headerClass="py-2" >
-
+

{@html $t( "rewards.by-amount-or-more", diff --git a/src/components/TestSlider.svelte b/src/components/TestSlider.svelte index a73486e9..08da89a6 100644 --- a/src/components/TestSlider.svelte +++ b/src/components/TestSlider.svelte @@ -4,7 +4,7 @@ {#each Array(20) as _, i} -
+
Slide {i + 1}
{/each} diff --git a/src/components/ThemeToggle.astro b/src/components/ThemeToggle.astro index 4b738aa7..85d13736 100644 --- a/src/components/ThemeToggle.astro +++ b/src/components/ThemeToggle.astro @@ -1,5 +1,5 @@ + + {#if open} +
    + {#each languages as lang (lang)} +
  • + +
  • + {/each} +
+ {/if} +
diff --git a/src/components/home/CampaignCard.svelte b/src/components/home/CampaignCard.svelte index 5a2c64c5..f1136e30 100644 --- a/src/components/home/CampaignCard.svelte +++ b/src/components/home/CampaignCard.svelte @@ -37,7 +37,7 @@ Converted from CampaignCard.astro to maintain exact functionality size === "large" ? "col-span-1 md:col-span-2 lg:col-span-2" : "col-span-1", ); - const imageHeight = "h-[215px]"; // More rectangular proportions matching design + const imageHeight = "h-53.75"; // More rectangular proportions matching design // Calculate funding status and remaining amount const hasReachedMinimum = $derived(campaign.obtained.amount >= campaign.minimum.amount); @@ -81,7 +81,7 @@ Converted from CampaignCard.astro to maintain exact functionality
- + + +
diff --git a/src/components/home/PromoterCard.astro b/src/components/home/PromoterCard.astro index 866e2793..c7837f61 100644 --- a/src/components/home/PromoterCard.astro +++ b/src/components/home/PromoterCard.astro @@ -12,12 +12,12 @@ const { t } = Astro.locals;
-
+
-
+
-
+
-
+
-
+
-
+
-
- -
- -
- -

- {t("pages.home.stories.title")} -

- - -

- {t("pages.home.stories.subtitle")} -

- - -
- { - t("pages.home.stories.description") - .split("\n") - .map((paragraph, index) => ( -

0 && "mt-4"]}> - {paragraph} -

- )) - } -
-
- - - - {t("pages.home.stories.button")} - -
- - -
- -
-
- - -
-
- - -
-
-
-
-

diff --git a/src/components/home/Stories.svelte b/src/components/home/Stories.svelte new file mode 100644 index 00000000..576e8e55 --- /dev/null +++ b/src/components/home/Stories.svelte @@ -0,0 +1,68 @@ + + +
+
+ +
+ +
+ +

+ {$t("pages.home.stories.title")} +

+ + +

+ {$t("pages.home.stories.subtitle")} +

+ + +
+ {#each $t("pages.home.stories.description").split("\n") as paragraph, index} +

0}> + {paragraph} +

+ {/each} +
+
+ + + + {$t("pages.home.stories.button")} + +
+ + +
+ +
+ + +
+ + +
+
+
+
diff --git a/src/components/library/Button.svelte b/src/components/library/Button.svelte index 938445ca..3187c40f 100644 --- a/src/components/library/Button.svelte +++ b/src/components/library/Button.svelte @@ -5,8 +5,8 @@ import type { HTMLButtonAttributes } from "svelte/elements"; const sizeStyles = { - md: "px-8 py-4 rounded-[24px]", - sm: "px-4 py-2 rounded-[16px]", + md: "px-8 py-4 rounded-3xl", + sm: "px-4 py-2 rounded-2xl", }; const kindStyles = { diff --git a/src/components/library/CategorySelect.svelte b/src/components/library/CategorySelect.svelte index 9fed20cc..d39161d8 100644 --- a/src/components/library/CategorySelect.svelte +++ b/src/components/library/CategorySelect.svelte @@ -1,10 +1,7 @@
-
+
diff --git a/src/components/library/Select.svelte b/src/components/library/Select.svelte index 0547b502..d36f846e 100644 --- a/src/components/library/Select.svelte +++ b/src/components/library/Select.svelte @@ -106,7 +106,7 @@ {#if labelText}