Merge branch 'develop' of github.com:goteo/org.goteo.www into feat/home-banners-real-data

This commit is contained in:
Angel Di Pinto
2026-08-27 12:54:44 +02:00
29 changed files with 524 additions and 1008 deletions
+3 -5
View File
@@ -29,11 +29,9 @@ PUBLIC_TIPPING_TIPJAR_ID=""
PUBLIC_TIPPING_DEFAULT_AMOUNT=""
PUBLIC_TIPPING_DEFAULT_CHECKED="false"
# Campaign limit
# Set a recurring day and month (DD-MM) to prevent projects from being in
# campaign past that date; the year is implicit and repeats every year
# Leave the value empty to disable the limit
PUBLIC_CAMPAIGN_MAX_END_DATE=""
# Campaign limits
PUBLIC_CAMPAIGN_MIN_REV_DAYS=14
PUBLIC_CAMPAIGN_MAX_END_DATE="" # Accepts partial values, leave empty to disable limit
# Facebook's share dialog
# https://developers.facebook.com/docs/sharing/reference/share-dialog
@@ -299,8 +299,7 @@
labelText={$t("pages.admin.charges.headers.amount")}
helperText={$t("pages.admin.charges.create.fields.amountHelper")}
required={true}
onInput={(e) =>
onAmountInput(e.target instanceof HTMLInputElement ? e.target.value : "")}
onInput={(value) => onAmountInput(value.toString())}
onBlur={onAmountBlur}
/>
<Select
+144 -326
View File
@@ -1,255 +1,151 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { twJoin } from "tailwind-merge";
import { t } from "../../i18n/store";
import { locale, t } from "../../i18n/store";
import {
apiProjectsPost,
apiProjectsIdPatch,
type Category,
type ProjectProjectCreationDto,
type ApiProjectsPostData,
type Territory,
apiCategoriesIdOrSlugGetUrl,
apiProjectsPost,
} from "../../openapi/client";
import { client } from "../../openapi/client/client.gen";
import { apiCategoriesIdOrSlugGetUrl } from "../../openapi/client/operation-paths.gen";
import { CAMPAIGN_MAX_END_DATE, CAMPAIGN_MIN_START_DATE } from "../../utils/dates";
import { getValidationParams } from "../../utils/validation";
import {
validateCreateForm,
validateField,
type ValidationErrors,
} from "../../stores/drafts/draftValidation";
import {
createDraft,
currentDraft,
markFieldAsTouched,
touchedFields,
updateProject,
isCreateFormValid,
project,
validationErrors,
type CreateProjectForm,
} from "../../stores/drafts/projectDraft";
import { maxEndDate } from "../../utils/campaign";
import { getDefaultCurrency } from "../../utils/consts";
import { formatCurrency } from "../../utils/currencies";
zCreateProjectForm,
zProjectCampaignRelease,
} from "../../validation/projectValidation";
import Button from "../library/buttons/Button.svelte";
import BaseCard from "../library/cards/BaseCard.svelte";
import AddressAutocomplete from "../library/inputs/AddressAutocomplete.svelte";
import CategorySelect from "../library/inputs/CategorySelect.svelte";
import DateInput from "../library/inputs/DateInput.svelte";
import TerritoryInput from "../library/inputs/TerritoryInput.svelte";
import TextArea from "../library/inputs/TextArea.svelte";
import TextInput from "../library/inputs/TextInput.svelte";
import Title from "../library/typography/Title.svelte";
let releaseDate = $state($project.release ? new Date($project.release) : new Date());
import type z from "zod";
let { categories }: { categories: Category[] } = $props();
// Track if form has been submitted once (for showing all errors)
let submitted = $state(false);
let form: Partial<ApiProjectsPostData["body"]> = $state({
title: "",
subtitle: "",
categories: [],
calendar: {
release: CAMPAIGN_MIN_START_DATE.toISOString(),
},
});
// Loading state for form submission
let isSubmitting = $state(false);
let validation: Partial<Record<keyof typeof form, z.core.$ZodIssue[]>> = $state({});
let isValid = $derived(Object.values(validation).every((issues) => !issues?.length));
// API error message
let apiError: string | null = $state(null);
function validate(field: keyof typeof form) {
const result = zCreateProjectForm.shape[field].safeParse(form[field]);
// Success state
let submitSuccess = $state(false);
validation[field] = result.error?.issues;
}
// Debounce timer for real-time validation
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function hasError(field: keyof typeof form) {
return validation[field] && validation[field].length > 0;
}
function handleFieldBlur(fieldName: keyof CreateProjectForm) {
if (!$currentDraft) return;
function getValidationMessage(field: keyof typeof form) {
const issue = validation[field]?.[0];
markFieldAsTouched(fieldName);
const error = validateField(fieldName, $currentDraft.createProject[fieldName]);
if (!issue) {
return "";
}
validationErrors.update((errors: ValidationErrors) => {
if (error) {
return {
...errors,
[fieldName]: error,
};
}
if (issue.code === "custom") {
return $t(issue.message, issue.params);
}
const { [fieldName]: _, ...rest } = errors;
if (issue.code === "invalid_format" && field === "title") {
return $t("pages.project.create.validation.titleBadFormat");
}
return rest;
if (issue.code === "too_small" && field === "categories") {
return $t("pages.project.create.validation.categoriesNotEnough", {
min: String(issue.minimum),
});
}
if (issue.code === "invalid_type" && field === "territory") {
return $t("pages.project.create.validation.territoryMissing");
}
if (issue.code === "invalid_type" && field === "calendar") {
return $t("pages.project.create.validation.releaseMissing");
}
return $t(`system.validation.${issue.code}`, {
value: String(form[field]),
...getValidationParams(issue),
});
}
/**
* Handles field value changes with type-safe updates.
* Uses generic typing to ensure type safety without bypassing TypeScript checks.
*/
function handleFieldChange<K extends keyof CreateProjectForm>(
fieldName: K,
value: CreateProjectForm[K],
) {
// Update the draft value with proper typing
updateProject({ [fieldName]: value });
// Only validate on change if field has been touched
if ($touchedFields.has(fieldName) || submitted) {
// Debounce validation
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const error = validateField(fieldName, value);
validationErrors.update((errors: ValidationErrors) => {
if (error) {
return {
...errors,
[fieldName]: error,
};
}
const { [fieldName]: _, ...rest } = errors;
return rest;
});
}, 300);
}
}
// Cleanup debounce timer on component unmount to prevent memory leaks
onDestroy(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
});
function handleCategoryChange(selected: Category[]) {
const categoryIris = selected.map((s) => {
return client.buildUrl({ url: apiCategoriesIdOrSlugGetUrl, path: { idOrSlug: s.id } });
function handleCategories(categories: Category[]) {
form.categories = categories.map((c) => {
return client.buildUrl({
url: apiCategoriesIdOrSlugGetUrl,
path: { idOrSlug: String(c.id) },
});
});
handleFieldChange("categories", categoryIris);
validate("categories");
}
async function handleSubmit() {
submitted = true;
apiError = null;
function handleTerritory(territory: Territory) {
form.territory = territory;
const errors = validateCreateForm($project);
validate("territory");
}
// Validate entire form
const isValid = Object.keys(errors).length === 0;
function handleRelease(release: Date) {
form.calendar = { ...form.calendar, release: release.toISOString() };
if (!isValid) {
// Scroll to error summary
const errorSummary = document.querySelector('[role="alert"]');
if (errorSummary) {
errorSummary.scrollIntoView({ behavior: "smooth", block: "start" });
} else {
// If no error summary, focus first invalid field
const firstError = Object.keys($validationErrors)[0];
if (firstError) {
const element = document.querySelector(`[name="${firstError}"]`);
if (element instanceof HTMLElement) {
element.focus();
}
}
const result = zProjectCampaignRelease.safeParse(new Date(release));
validation["calendar"] = result.error?.issues ?? [];
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
validation = {};
const result = zCreateProjectForm.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof form;
validation[field] = [...(validation[field] ?? []), issue];
}
return;
}
if (!$currentDraft) {
apiError = "Draft not initialized";
const { data: project, error } = await apiProjectsPost({
baseUrl: "/api/relay",
headers: { "Content-Language": $locale },
// @ts-expect-error form is declared as Partial, but at this point has been validated
body: form,
});
if (error) {
console.error(error);
return;
}
// Submit to API
isSubmitting = true;
try {
const { data, error } = await apiProjectsPost({
baseUrl: "/api/relay",
body: {
title: $currentDraft.createProject.title,
subtitle: $currentDraft.createProject.subtitle,
categories: $currentDraft.createProject.categories,
calendar: { release: $currentDraft.createProject.release ?? null },
},
});
if (error) {
if ("violations" in error && error.violations) {
error.violations.forEach((violation) => {
const field = violation.propertyPath as keyof ProjectProjectCreationDto;
if (field) {
$validationErrors[field] = violation.message || "Invalid value";
}
});
return;
} else {
apiError = "An unexpected error occurred. Please try again.";
return;
}
} else if (data) {
const address = $currentDraft.createProject.address;
const territory = $currentDraft.createProject.territory;
if (address && territory) {
await apiProjectsIdPatch({
baseUrl: "/api/relay",
path: { id: String(data.id) },
body: {
territory: {
...territory,
address,
},
},
});
}
submitSuccess = true;
updateProject({
title: $currentDraft.createProject.title,
subtitle: $currentDraft.createProject.subtitle,
categories: $currentDraft.createProject.categories,
release: $currentDraft.createProject.release,
address: $currentDraft.createProject.address,
territory: $currentDraft.createProject.territory,
});
currentDraft.update((d) => (d ? { ...d, status: "project-created" } : d));
// Redirect to project page
window.location.href = `/project/${data.id}/edit`;
}
} catch {
// Handle unexpected errors
apiError = "An unexpected error occurred. Please try again.";
} finally {
isSubmitting = false;
}
window.location.href = `/project/${project.id}/edit`;
}
// Helper to check if field should show error
function shouldShowError(fieldName: string): boolean {
return (submitted || $touchedFields.has(fieldName)) && !!$validationErrors[fieldName];
}
// Calculate minimum date (14 days from now) for date input
function getMinDate(): Date {
const minDate = new Date();
minDate.setDate(minDate.getDate() + 14);
return minDate;
}
$effect(() => {
if (!$currentDraft) {
createDraft({
title: "",
subtitle: "",
categories: [],
release: undefined,
});
}
});
</script>
<section class="wrapper md:flex md:flex-row">
<div class="mb-20 flex max-w-167 flex-col gap-10">
<form class="mb-20 flex max-w-167 flex-col gap-10" onsubmit={handleSubmit}>
<div class="flex flex-col gap-4">
<Title level={1} variant="section">
{$t("pages.project.create.title")}
@@ -258,92 +154,47 @@
{$t("pages.project.create.subtitle")}
</p>
</div>
{#if submitted && Object.keys($validationErrors).length > 0}
<div
role="alert"
aria-live="polite"
class="rounded-md border-l-4 border-red-500 bg-red-50 p-4"
>
<p class="mb-2 text-lg font-semibold text-red-800">
{$t("system.validation.errors.summary.title")}
</p>
<ul class="list-inside list-disc space-y-1">
{#each Object.entries($validationErrors) as [field, error]}
<li class="text-sm text-red-700">
<a
href={`#${field}`}
class="underline hover:text-red-900"
onclick={(e) => {
e.preventDefault();
const element = document.querySelector(`[name="${field}"]`);
if (element instanceof HTMLElement) {
element.focus();
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}}
>
{$t(error)}
</a>
</li>
{/each}
</ul>
</div>
{/if}
<div class="flex flex-col gap-4">
<Title level={2} variant="subsection">
{$t("pages.project.create.description.title")}
</Title>
<p class="text-black transition-all duration-300 ease-in-out">
{$t("pages.project.create.description.subtitle")}
{$t("pages.project.create.description.description")}
</p>
<TextInput
name="title"
placeholder={$t("pages.project.create.description.titlePrompt")}
bind:value={$project.title}
error={shouldShowError("title") ? $t($validationErrors.title) : undefined}
onBlur={() => handleFieldBlur("title")}
onInput={(e) => handleFieldChange("title", (e.target as HTMLInputElement).value)}
bind:value={form.title}
onInput={() => validate("title")}
helperText={$t("pages.project.create.description.titlePrompt")}
placeholder={$t("pages.project.create.description.titlePlaceholder")}
error={getValidationMessage("title")}
required
/>
<div class="relative">
<textarea
id="subtitle"
name="subtitle"
placeholder={$t("pages.project.create.description.subtitlePrompt")}
class="h-60 w-full resize-none rounded-md border p-4 {shouldShowError(
'subtitle',
)
? 'border-red-500'
: 'border-[#855a96]'}"
bind:value={$project.subtitle}
onblur={() => handleFieldBlur("subtitle")}
oninput={(e) =>
handleFieldChange("subtitle", (e.target as HTMLTextAreaElement).value)}
aria-invalid={shouldShowError("subtitle")}
aria-describedby={shouldShowError("subtitle") ? "subtitle-error" : undefined}
></textarea>
{#if shouldShowError("subtitle")}
<p id="subtitle-error" class="mt-1 ml-4 text-xs text-red-600" role="alert">
{$t($validationErrors.subtitle)}
</p>
{/if}
<TextArea
bind:value={form.subtitle}
helperText={$t("pages.project.create.description.subtitlePrompt")}
placeholder={$t("pages.project.create.description.subtitlePlaceholder")}
/>
</div>
</div>
<div class="flex flex-col gap-4">
<Title level={2} variant="subsection">
{$t("pages.project.create.categories.title")}
</Title>
<p class="text-black transition-all duration-300 ease-in-out">
{$t("pages.project.create.categories.subtitle")}
<p
class={twJoin(
"transition-all duration-300 ease-in-out",
hasError("categories") ? "text-tertiary" : "text-black",
)}
>
{#if hasError("categories")}
{getValidationMessage("categories")}
{:else}
{$t("pages.project.create.categories.subtitle")}
{/if}
</p>
<CategorySelect
max={2}
options={categories}
onchange={handleCategoryChange}
error={shouldShowError("categories") ? $t($validationErrors.categories) : undefined}
/>
<CategorySelect max={2} options={categories} onChange={handleCategories} />
</div>
<div class="flex flex-col gap-4">
<Title level={2} variant="subsection">
@@ -352,16 +203,10 @@
<p class="text-black transition-all duration-300 ease-in-out">
{$t("pages.project.create.address.subtitle")}
</p>
<AddressAutocomplete
name="address"
<TerritoryInput
placeholder={$t("pages.project.create.address.placeholder")}
value={$project.address ?? ""}
error={shouldShowError("address") ? $t($validationErrors.address) : undefined}
onAddressChange={(address, territory) => {
handleFieldChange("address", address);
handleFieldChange("territory", territory);
}}
onBlur={() => handleFieldBlur("address")}
onInput={handleTerritory}
error={getValidationMessage("territory")}
/>
</div>
<div class="flex flex-col gap-4">
@@ -372,35 +217,19 @@
{$t("pages.project.create.release.subtitle")}
</p>
<DateInput
name="release"
bind:value={releaseDate}
min={getMinDate()}
max={maxEndDate}
error={shouldShowError("release") ? $t($validationErrors.release) : undefined}
onBlur={() => handleFieldBlur("release")}
onInput={(date: any) => handleFieldChange("release", date)}
min={CAMPAIGN_MIN_START_DATE}
max={CAMPAIGN_MAX_END_DATE}
value={new Date(form.calendar?.release!)}
helperText={$t("pages.project.create.release.label")}
required
onInput={handleRelease}
error={getValidationMessage("calendar")}
/>
</div>
{#if apiError}
<div role="alert" class="rounded-md border-l-4 border-red-500 bg-red-50 p-4">
<p class="text-sm text-red-800">{apiError}</p>
</div>
{/if}
{#if submitSuccess}
<div role="status" class="rounded-md border-l-4 border-green-500 bg-green-50 p-4">
<p class="text-sm font-semibold text-green-800">
{$t("pages.project.create.submitSuccess")}
</p>
</div>
{/if}
<p>
<Button size="md" disabled={!$isCreateFormValid || isSubmitting} onclick={handleSubmit}>
{isSubmitting
? $t("pages.project.create.submitting")
: $t("pages.project.create.submit")}
</Button>
</p>
</div>
<Button type="submit" disabled={!isValid}>
{$t("pages.project.create.submit")}
</Button>
</form>
<div class="ml-auto">
<BaseCard
class="border-grey flex h-full max-h-126.5 w-full max-w-109.25 flex-col bg-white"
@@ -410,26 +239,15 @@
level={2}
variant="section"
color="secondary"
class="mb-2 {!$currentDraft?.createProject.title ? 'opacity-24' : ''}"
class={twJoin("mb-2", !form.title && "opacity-24")}
>
{$currentDraft?.createProject.title ||
$t("pages.project.create.description.titlePlaceholder")}
{form.title || $t("pages.project.create.description.titlePlaceholder")}
</Title>
<p
class="text-content overflow-hidden text-base font-normal text-ellipsis whitespace-nowrap"
class="text-content w-full overflow-hidden text-base font-normal text-ellipsis whitespace-nowrap"
>
{$currentDraft?.createProject.subtitle ||
$t("pages.project.create.description.subtitlePlaceholder")}
{form.subtitle || $t("pages.project.create.description.subtitlePlaceholder")}
</p>
<div class="mt-auto">
<p class="text-sm text-black">{$t("pages.project.create.budgetPreview")}</p>
<p class="text-secondary text-3xl font-bold">
{formatCurrency(
$currentDraft?.wizardForm.budget?.minimum?.money?.amount || 0,
getDefaultCurrency(),
)}
</p>
</div>
</BaseCard>
</div>
</section>
@@ -24,7 +24,6 @@
clearable?: boolean;
onClear?: () => void;
singleSelect?: boolean;
inputName?: string;
onInputBlur?: (e?: FocusEvent) => void;
/** Build the list with selected options at the top. */
label?: string;
@@ -46,7 +45,6 @@
clearable = false,
onClear = undefined,
singleSelect = false,
inputName = undefined,
onInputBlur = undefined,
label = undefined,
selectedFirst = false,
@@ -122,7 +120,6 @@
<input
class="max-h-6 w-full border-0 bg-white p-0 text-base/6 font-normal text-black ring-0 placeholder:opacity-48"
type="text"
name={inputName}
placeholder={searchPlaceholder}
bind:value={searchValue}
oninput={(e) => onSearch?.(e.currentTarget.value)}
@@ -131,16 +128,18 @@
if (!isOpen) isOpen = true;
}}
/>
{#if clearable && searchValue}
<button
type="button"
class="absolute right-14 shrink-0 hover:cursor-pointer hover:opacity-75"
onclick={handleClear}
>
<Close width="20" height="20" />
</button>
{/if}
<SearchIcon class="absolute right-4" width="32" height="32" />
<div class="absolute right-4 flex flex-row gap-1 bg-white">
{#if clearable && searchValue}
<button
type="button"
class="shrink-0 hover:cursor-pointer hover:opacity-75"
onclick={handleClear}
>
<Close width="20" height="20" />
</button>
{/if}
<SearchIcon width="32" height="32" />
</div>
</div>
{:else}
<button
@@ -8,31 +8,31 @@
selected = $bindable([]),
selectedIds = $bindable([]),
max,
onchange,
onChange,
error = undefined,
}: {
options: Category[];
selected?: Category[];
selectedIds?: (number | string)[];
max?: number;
onchange?: (selected: Category[], option: Category) => void;
onChange?: (selected: Category[], option: Category) => void;
error?: string;
} = $props();
function isSelected(option: Category): boolean {
return selectedIds.includes(option.id);
return selectedIds.includes(option.id!);
}
function handleClick(option: Category): void {
if (isSelected(option)) {
selectedIds = selectedIds.filter((id) => id !== option.id);
} else {
selectedIds = selectedIds.concat(option.id);
selectedIds = selectedIds.concat(option.id!);
}
selected = options.filter((option) => selectedIds.includes(option.id));
selected = options.filter((option) => selectedIds.includes(option.id!));
onchange?.(selected, option);
onChange?.(selected, option);
}
function calcTagType(option: Category) {
@@ -1,5 +1,5 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import { twJoin, twMerge, type ClassNameValue } from "tailwind-merge";
import {
searchPlace,
@@ -15,9 +15,9 @@
class?: ClassNameValue;
value?: string;
placeholder?: string;
name?: string;
helperText?: string;
error?: string;
onAddressChange?: (address: string, territory: Territory) => void;
onInput?: (territory: Territory) => void;
onBlur?: () => void;
}
@@ -25,9 +25,9 @@
class: classes = undefined,
value = $bindable(""),
placeholder,
name = "address",
helperText,
error = undefined,
onAddressChange = undefined,
onInput = undefined,
onBlur = undefined,
}: Props = $props();
@@ -63,14 +63,14 @@
value = result.display_name;
const territory = extractTerritory(result);
onAddressChange?.(result.display_name, territory);
onInput?.(territory);
}
function handleClear() {
value = "";
results = [];
selected = [];
onAddressChange?.("", {
onInput?.({
country: null,
subLvl1: null,
subLvl2: null,
@@ -83,10 +83,10 @@
<DropdownMenu
variant="basic"
hasSearch
searchClasses={error && "border-tertiary border"}
singleSelect
clearable
bind:searchValue={value}
inputName={name}
searchPlaceholder={placeholder}
{options}
bind:selected
@@ -95,7 +95,9 @@
onClear={handleClear}
onInputBlur={() => onBlur?.()}
/>
{#if error}
<p class="mt-1 ml-4 text-xs text-red-600" role="alert">{error}</p>
{/if}
<p class={twJoin("mt-1 ml-4 text-xs", !error && "text-content", error && "text-tertiary")}>
{#if error || helperText}
{error || helperText}
{/if}
</p>
</div>
@@ -28,7 +28,7 @@
labelText?: string;
helperText?: string;
error?: string;
onInput?: (event: Event) => void;
onInput?: (value: string | number) => void;
onBlur?: (event?: FocusEvent) => void;
onFocus?: (event?: FocusEvent) => void;
} = $props();
@@ -55,7 +55,7 @@
id={finalId}
onblur={onBlur}
onfocus={onFocus}
oninput={onInput}
oninput={() => onInput?.(value)}
{name}
{type}
{required}
+3 -3
View File
@@ -90,9 +90,9 @@
<div
class:opacity-50={!isAvailable}
class:cursor-not-allowed={!isAvailable}
class="border-grey flex basis-1/3 flex-col justify-between gap-6 rounded-4xl border bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A]"
class="border-grey flex basis-1/3 flex-col justify-between rounded-4xl border bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A]"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3">
<Title
level={3}
variant="subsection"
@@ -102,7 +102,7 @@
>
{$t("pages.project.view.rewards.donationFree.title")}
</Title>
<p class="text-sm whitespace-pre-line text-gray-800">
<p class="text-content text-base whitespace-pre-line">
{$t("pages.project.view.rewards.donationFree.description")}
</p>
</div>
+4 -2
View File
@@ -28,7 +28,7 @@
class:opacity-50={!isAvailable}
class:cursor-not-allowed={!isAvailable}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Title
level={3}
variant="subsection"
@@ -50,7 +50,9 @@
</Title>
{#if reward.description}
<div class="marked-content line-clamp-7 text-sm whitespace-pre-line text-gray-800">
<div
class="marked-content line-clamp-6 max-h-[11.2em] overflow-hidden mask-[linear-gradient(to_bottom,black_80%,transparent)] text-sm whitespace-pre-line text-gray-800"
>
{#await renderMarkdown(reward.description) then description}
{@html description}
{/await}
+3 -3
View File
@@ -78,12 +78,12 @@
<Modal
bind:open
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="fixed top-1/2 left-1/2 w-full max-w-225 -translate-x-1/2 -translate-y-1/2 bg-transparent backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
closeBtnClass="top-7 end-12 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="fixed top-1/2 left-1/2 w-full max-w-225 -translate-x-1/2 -translate-y-1/2 divide-y-0 bg-transparent backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
bodyClass="p-0"
>
<div
class="flex flex-col gap-6 rounded-3xl bg-white p-8 shadow-lg"
class="flex max-h-[85vh] flex-col gap-6 overflow-y-auto rounded-3xl bg-white p-8 shadow-lg"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
+2 -26
View File
@@ -1,49 +1,25 @@
<script lang="ts">
import iso3166 from "iso-3166-2";
import { type ClassNameValue } from "tailwind-merge";
import { locale } from "../../i18n/store";
import { getTerritoryDisplayName } from "../../utils/territory";
import MapIcon from "../icons/Location.svelte";
import Tag from "../library/tags/Tag.svelte";
import type { Locale } from "../../i18n/locales";
import type { Territory } from "../../openapi/client";
let {
territory,
lang: initialLang = undefined,
class: classes = "",
iconSize = "32",
}: {
territory: Territory;
lang?: Locale;
class?: ClassNameValue;
iconSize?: string | number;
} = $props();
let lang = $derived(initialLang || $locale);
function displayName(territory: Territory): string {
const countryNames = new Intl.DisplayNames(lang, { type: "region" });
const country = countryNames.of(territory.country!);
const tag = territory.subLvl2 ?? territory.subLvl1;
if (!tag) {
return country!;
}
const iso = iso3166.subdivision(tag!);
if (!iso || !iso.name) {
return country!;
}
const subdivision = iso.name.split(",")[0];
return `${subdivision}, ${country}`;
}
</script>
<Tag class={classes}>
<MapIcon width={iconSize} height={iconSize} />
<span>{displayName(territory)}</span>
<span>{getTerritoryDisplayName(territory, $locale)}</span>
</Tag>
+4 -2
View File
@@ -33,7 +33,7 @@
</script>
<li
class="border-grey flex basis-1/3 flex-col items-center justify-between gap-8 rounded-4xl border bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A]"
class="border-grey flex basis-1/3 flex-col items-center justify-between gap-4 rounded-4xl border bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A]"
class:opacity-50={!isAvailable}
class:cursor-not-allowed={!isAvailable}
>
@@ -50,7 +50,9 @@
</Title>
{#if reward.description}
<div class="marked-content line-clamp-7 text-sm whitespace-pre-line text-gray-800">
<div
class="marked-content line-clamp-6 max-h-[11.2em] overflow-hidden mask-[linear-gradient(to_bottom,black_80%,transparent)] text-sm whitespace-pre-line text-gray-800"
>
{#await renderMarkdown(reward.description) then description}
{@html description}
{/await}
@@ -130,7 +130,7 @@
max={2}
{options}
bind:selectedIds={categories}
onchange={handleCategoryChange}
onChange={handleCategoryChange}
/>
</div>
{/if}
@@ -85,7 +85,7 @@
placeholder={$t("pages.project.edit.campaignInfo.media.videoPlaceholder")}
bind:value={videoUrl}
error={validationError}
onInput={(e) => handleUrlChange((e.target as HTMLInputElement).value)}
onInput={(value) => handleUrlChange(value.toString())}
/>
{/if}
</div>
+1 -1
View File
@@ -63,7 +63,7 @@ Implements active/inactive pill states matching Figma design
bind:selected
selectedIds={selected.map((s) => s.id!)}
options={categories}
onchange={(selected) => onCategoryChange?.(selected.map((o) => `${o.id}`))}
onChange={(selected) => onCategoryChange?.(selected.map((o) => `${o.id}`))}
/>
{/await}
+43 -29
View File
@@ -35,13 +35,24 @@
}
},
"validation": {
"_invalid_type": "",
"_too_big": "",
"too_small": "\"{{ value }}\" és massa curt: s'esperen {{ min }} caràcters o més.",
"invalid_format": "\"{{ value }}\" té un format incorrecte: no s'ajusta al patró regex `{{ pattern }}`.",
"_not_multiple_of": "",
"_unrecognized_keys": "",
"_invalid_union": "",
"_invalid_key": "",
"_invalid_element": "",
"_invalid_value": "",
"missingRequiredFields": "Un o més camps obligatoris estan buits",
"project": {
"terms": {
"required": "Has d'acceptar les condicions de l'impulsor i les bases de la convocatòria"
},
"release": {
"max": "La data d'inici és massa tardana: la campanya no es pot estendre més enllà de la data límit de la plataforma"
"min": "La data de llançament no pot ser anterior a {{ min }}",
"max": "La data de llançament no pot ser posterior a {{ max }}"
}
},
"errors": {
@@ -629,6 +640,13 @@
"project": {
"edit": {
"title": "Editant projecte: {{ project }}",
"validation": {
"titleBadFormat": "El títol ha de contenir almenys una lletra."
},
"header": {
"titlePlaceholder": "Títol del projecte",
"unsentChanges": "Hi ha canvis no enviats"
},
"tabs": {
"configuration": "Configuració",
"campaign": "Informació de campanya",
@@ -647,6 +665,11 @@
"secondaryLabel": "Idioma secundari del contingut",
"addButton": "Afegir idioma"
},
"release": {
"title": "Data de sortida a campanya",
"subtitle": "Tingues en compte que les campanyes de micromecenatge tenen una durada mínima de 40 dies",
"passed": "Aquest projecte ja ha sortit a campanya i no es pot modificar la seva data de sortida"
},
"rounds": {
"title": "Tria les rondes que tindrà la teva campanya",
"description": "La configuració predeterminada és de 40 dies cada ronda, pots sol·licitar modificacions al teu assessor si el teu projecte és seleccionat",
@@ -872,54 +895,45 @@
},
"continue": "Pas següent",
"locationPlaceholder": "Ex.: Carrer Gran Via 28, Madrid"
},
"errors": {
"session": {
"title": "Sessió expirada"
},
"storage": {
"title": "Error d'emmagatzematge",
"save_failed": "No s'han pogut desar els canvis",
"quota_exceeded": "Emmagatzematge local ple",
"close": "Tancar"
}
}
},
"create": {
"title": "Proposa una campanya",
"subtitle": "Descriu breument el projecte i la campanya de crowdfunding.",
"budgetPreview": "Diners necessaris",
"title": "Crea un projecte",
"subtitle": "Descriu breument el teu projecte de micromecenatge.",
"description": {
"title": "Descriu breument la teva proposta",
"titlePrompt": "Indica el nom de la teva campanya",
"titlePlaceholder": "Nom de campanya",
"subtitle": "Indica el nom de la teva campanya i descriu breument el projecte que duràs a terme si aconsegueixes el finançament.",
"description": "Indica el nom del teu projecte i descriu breument què duràs a terme si aconsegueixes el finançament.",
"titlePrompt": "Indica el nom del teu projecte",
"titlePlaceholder": "El meu projecte",
"subtitlePrompt": "Indica breument la descripció del teu projecte.",
"subtitlePlaceholder": "Sense descripció"
"subtitlePlaceholder": "Un projecte per fer que el món sigui una miqueta millor"
},
"categories": {
"title": "Quina categoria s'ajusta a la teva campanya?",
"title": "Quina categoria s'ajusta al teu projecte?",
"subtitle": "(Pots triar un màxim de 2)"
},
"address": {
"title": "Quina és l'adreça del projecte?",
"subtitle": "Cerca i selecciona l'adreça completa del projecte. Això ens ajuda a ubicar la teva iniciativa.",
"placeholder": "Ex.: Carrer Gran Via 28, Madrid"
},
"budget": {
"title": "Quants diners necessites?",
"subtitle": "Selecciona la quantitat que necessites per dur a terme el teu projecte."
"placeholder": "Forn de l'Olivera 22, Palma"
},
"release": {
"title": "Data de sortida de la campanya",
"subtitle": "(Tingues en compte que les campanyes de crowdfunding tenen una durada mínima de 40 dies)"
"title": "Data de llançament de la campanya",
"subtitle": "(Tingues en compte que les campanyes de crowdfunding tenen una durada mínima de 40 dies)",
"label": "Data de llançament aproximada"
},
"terms": {
"label": "Accepto les condicions de l'impulsor i les bases de la convocatòria"
},
"submit": "Proposar campanya",
"submit": "Crear projecte",
"submitting": "Enviant…",
"submitSuccess": "Campanya proposada correctament!"
"submitSuccess": "Projecte creat",
"validation": {
"titleBadFormat": "El títol ha de contenir almenys una lletra.",
"categoriesNotEnough": "Has de seleccionar almenys {{ min }} categoria/es.",
"territoryMissing": "Has d'especificar un lloc.",
"releaseMissing": "Has d'especificar una data de llançament aproximada"
}
},
"view": {
"owner": "Campanya de crowdfunding impulsada per {{ owner }}",
+42 -28
View File
@@ -35,13 +35,24 @@
}
},
"validation": {
"_invalid_type": "",
"_too_big": "",
"too_small": "\"{{ value }}\" is too short: {{ min }} or more characters expected.",
"invalid_format": "\"{{ value }}\" has an invalid format: it does not match the regex pattern `{{ pattern }}`.",
"_not_multiple_of": "",
"_unrecognized_keys": "",
"_invalid_union": "",
"_invalid_key": "",
"_invalid_element": "",
"_invalid_value": "",
"missingRequiredFields": "One or more required fields are empty",
"project": {
"terms": {
"required": "You must accept the promoter conditions and the call terms"
},
"release": {
"max": "The start date is too late: the campaign cannot extend beyond the platform deadline"
"min": "The launch date cannot be before {{ min }}",
"max": "The launch date cannot be after {{ max }}"
}
},
"errors": {
@@ -629,6 +640,13 @@
"project": {
"edit": {
"title": "Editing project: {{ project }}",
"validation": {
"titleBadFormat": "The title must contain at least one letter."
},
"header": {
"titlePlaceholder": "Project title",
"unsentChanges": "There are unsent changes"
},
"tabs": {
"configuration": "Configuration",
"campaign": "Campaign information",
@@ -647,6 +665,11 @@
"secondaryLabel": "Secondary content language",
"addButton": "Add language"
},
"release": {
"title": "Campaign launch date",
"subtitle": "Please note that crowdfunding campaigns have a minimum duration of 40 days",
"passed": "This project has already launched as a campaign and its launch date cannot be changed"
},
"rounds": {
"title": "Choose the rounds your campaign will have",
"description": "The default setting is 40 days each round. You can request modifications through your advisor if your project is selected",
@@ -872,54 +895,45 @@
},
"continue": "Next step",
"locationPlaceholder": "E.g.: Calle Gran Vía 28, Madrid"
},
"errors": {
"session": {
"title": "Session expired"
},
"storage": {
"title": "Storage error",
"save_failed": "Changes could not be saved",
"quota_exceeded": "Local storage full",
"close": "Close"
}
}
},
"create": {
"title": "Propose a campaign",
"subtitle": "Briefly describe the project and the crowdfunding campaign.",
"budgetPreview": "Money needed",
"title": "Create a project",
"subtitle": "Briefly describe your crowdfunding project.",
"description": {
"title": "Briefly describe your proposal",
"titlePrompt": "Enter your campaign name",
"titlePlaceholder": "Campaign name",
"subtitle": "Enter your campaign name and briefly describe the project you will carry out if you obtain funding.",
"description": "Enter the name of your project and briefly describe what you will do if you secure funding.",
"titlePrompt": "Enter the name of your project",
"titlePlaceholder": "My project",
"subtitlePrompt": "Briefly describe your project.",
"subtitlePlaceholder": "No description"
"subtitlePlaceholder": "A project to make the world a little bit better"
},
"categories": {
"title": "Which category fits your campaign?",
"title": "Which category fits your project?",
"subtitle": "(You can choose a maximum of 2)"
},
"address": {
"title": "What is the project address?",
"subtitle": "Search for and select the project's full address. This helps us locate your initiative.",
"placeholder": "E.g.: Calle Gran Vía 28, Madrid"
},
"budget": {
"title": "How much money do you need?",
"subtitle": "Select the amount you need to carry out your project."
"placeholder": "Forn de l'Olivera 22, Palma"
},
"release": {
"title": "Campaign launch date",
"subtitle": "(Please note that crowdfunding campaigns have a minimum duration of 40 days)"
"subtitle": "(Please note that crowdfunding campaigns have a minimum duration of 40 days)",
"label": "Approximate launch date"
},
"terms": {
"label": "I accept the promoter conditions and the call terms"
},
"submit": "Propose campaign",
"submit": "Create project",
"submitting": "Submitting…",
"submitSuccess": "Campaign proposed successfully!"
"submitSuccess": "Project created",
"validation": {
"titleBadFormat": "The title must contain at least one letter.",
"categoriesNotEnough": "You must select at least {{ min }} category(ies).",
"territoryMissing": "You must specify a location.",
"releaseMissing": "You must specify an approximate launch date"
}
},
"view": {
"owner": "Crowdfunding campaign by {{ owner }}",
+21 -18
View File
@@ -51,7 +51,8 @@
"required": "Debes aceptar las condiciones del impulsor y las bases de la convocatoria"
},
"release": {
"max": "La fecha de inicio es demasiado tardía: la campaña no puede extenderse más allá de la fecha límite de la plataforma"
"min": "La fecha de salida no puede ser antes de {{ min }}",
"max": "La fecha de salida no puede ser después de {{ max }}"
}
},
"errors": {
@@ -897,40 +898,42 @@
}
},
"create": {
"title": "Propón una campaña",
"subtitle": "Describe brevemete el proyecto y la campaña de crowdfunding.",
"budgetPreview": "Dinero necesario",
"title": "Crea un proyecto",
"subtitle": "Describe brevemete tu proyecto de crowdfunding.",
"description": {
"title": "Describe brevemente tu propuesta",
"titlePrompt": "Indica el nombre de tu campaña",
"titlePlaceholder": "Nombre de campaña",
"subtitle": "Indica el nombre de tu campaña y describe brevemente el proyecto que realizarás si consigues la financiación.",
"description": "Indica el nombre de tu proyecto y describe brevemente lo que realizarás si consigues la financiación.",
"titlePrompt": "Indica el nombre de tu proyecto",
"titlePlaceholder": "Mi proyecto",
"subtitlePrompt": "Indica brevemente la descripción de tu proyecto.",
"subtitlePlaceholder": "Sin descripción"
"subtitlePlaceholder": "Un proyecto para hacer el mundo un poquito mejor"
},
"categories": {
"title": "¿Qué categoría se ajusta a tu campaña?",
"title": "¿Qué categoría se ajusta a tu proyecto?",
"subtitle": "(Puedes elegir un máximo de 2)"
},
"address": {
"title": "¿Cuál es la dirección del proyecto?",
"subtitle": "Busca y selecciona la dirección completa del proyecto. Esto nos ayuda a ubicar tu iniciativa.",
"placeholder": "Ej: Calle Gran Vía 28, Madrid"
},
"budget": {
"title": "¿Cuánto dinero necesitas?",
"subtitle": "Selecciona la cantidad que necesites para llevar a cabo tu proyecto."
"placeholder": "Forn de l'Olivera 22, Palma"
},
"release": {
"title": "Fecha salida de campaña",
"subtitle": "(Ten en cuenta que las campañas de crowdfunding tienen una duración mínima de 40 días)"
"title": "Fecha de salida a campaña",
"subtitle": "(Ten en cuenta que las campañas de crowdfunding tienen una duración mínima de 40 días)",
"label": "Fecha de salida aproximada"
},
"terms": {
"label": "Acepto las condiciones del impulsor y las bases de la convocatoria"
},
"submit": "Proponer campaña",
"submit": "Crear proyecto",
"submitting": "Enviando…",
"submitSuccess": "¡Campaña propuesta correctamente!"
"submitSuccess": "Proyecto creado",
"validation": {
"titleBadFormat": "El título debe contener al menos una letra.",
"categoriesNotEnough": "Debes seleccionar al menos {{ min }} categoría(s).",
"territoryMissing": "Debes especificar un lugar.",
"releaseMissing": "Debes especificar una fecha de salida aproximada"
}
},
"view": {
"owner": "Campaña de crowdfunding impulsada por {{ owner }}",
+12 -12
View File
@@ -1611,18 +1611,18 @@ export type ProjectProjectCreationDto = {
* Secondary headline for the Project.
*/
subtitle: string;
/**
* URL to an image resource to be displayed as header.
*/
cover?: string;
/**
* List of Categories.
*/
categories: Array<string>;
/**
* ISO 3166 data about the Project's territory of interest.
*/
territory: Territory;
/**
* Deadlines and important Project dates.
*/
calendar?: ProjectCalendar;
calendar: ProjectCalendar;
readonly status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
};
@@ -1638,18 +1638,18 @@ export type ProjectProjectCreationDtoJsonld = {
* Secondary headline for the Project.
*/
subtitle: string;
/**
* URL to an image resource to be displayed as header.
*/
cover?: string;
/**
* List of Categories.
*/
categories: Array<string>;
/**
* ISO 3166 data about the Project's territory of interest.
*/
territory: TerritoryJsonld;
/**
* Deadlines and important Project dates.
*/
calendar?: ProjectCalendarJsonld;
calendar: ProjectCalendarJsonld;
readonly status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
};
@@ -2423,7 +2423,7 @@ export type Territory = {
* ISO 3166-1 alpha-2 two-letter country code.\
* e.g: ES (Spain).
*/
country?: string | null;
country: string | null;
/**
* ISO 3166-2 first level subdivision code.\
* e.g: ES-AN (Andalucía, Spain).
@@ -2453,7 +2453,7 @@ export type TerritoryJsonld = {
* ISO 3166-1 alpha-2 two-letter country code.\
* e.g: ES (Spain).
*/
country?: string | null;
country: string | null;
/**
* ISO 3166-2 first level subdivision code.\
* e.g: ES-AN (Andalucía, Spain).
+64 -64
View File
@@ -590,37 +590,6 @@ export const zProjectCalendar = z.object({
optimum: z.iso.datetime().nullish()
});
/**
* Projects describe a User-owned, community-led event that is to be discovered, developed and funded by the community.
*/
export const zProjectProjectCreationDto = z.object({
title: z.string().regex(/^(.*[a-zA-Z]{1,}.*)$/),
subtitle: z.string(),
cover: z.url().optional(),
categories: z.array(z.string()).min(1).max(2),
calendar: zProjectCalendar.optional(),
status: z.enum([
'in_draft',
'to_campaign_review',
'in_campaign_review',
'in_campaign_review.to_change',
'in_campaign_review.to_review',
'campaign_review.rejected',
'to_campaign',
'in_campaign',
'campaign.failed',
'campaign.cancelled',
'to_funding_review',
'in_funding_review',
'in_funding_review.to_change',
'in_funding_review.to_review',
'funding_review.rejected',
'to_funding',
'in_funding',
'funding.paid'
]).readonly().optional().default('in_draft')
});
export const zProjectCalendarJsonld = z.object({
'@context': z.union([
z.string(),
@@ -636,37 +605,6 @@ export const zProjectCalendarJsonld = z.object({
optimum: z.iso.datetime().nullish()
});
/**
* Projects describe a User-owned, community-led event that is to be discovered, developed and funded by the community.
*/
export const zProjectProjectCreationDtoJsonld = z.object({
title: z.string().regex(/^(.*[a-zA-Z]{1,}.*)$/),
subtitle: z.string(),
cover: z.url().optional(),
categories: z.array(z.string()).min(1).max(2),
calendar: zProjectCalendarJsonld.optional(),
status: z.enum([
'in_draft',
'to_campaign_review',
'in_campaign_review',
'in_campaign_review.to_change',
'in_campaign_review.to_review',
'campaign_review.rejected',
'to_campaign',
'in_campaign',
'campaign.failed',
'campaign.cancelled',
'to_funding_review',
'in_funding_review',
'in_funding_review.to_change',
'in_funding_review.to_review',
'funding_review.rejected',
'to_funding',
'in_funding',
'funding.paid'
]).readonly().optional().default('in_draft')
});
/**
* ProjectCollaborations are used as requests for help on specific problems that the Project might face and cannot be budgeted-in.
*/
@@ -893,7 +831,7 @@ export const zProjectVideoJsonld = z.object({
});
export const zTerritory = z.object({
country: z.string().nullish(),
country: z.string().nullable(),
subLvl1: z.string().nullish(),
subLvl2: z.string().nullish(),
address: z.string().nullish()
@@ -932,6 +870,37 @@ export const zMatchCall = z.object({
]).optional().default('in_editing')
});
/**
* Projects describe a User-owned, community-led event that is to be discovered, developed and funded by the community.
*/
export const zProjectProjectCreationDto = z.object({
title: z.string().min(3).regex(/^(.*[a-zA-Z]{1,}.*)$/),
subtitle: z.string(),
categories: z.array(z.string()).min(1).max(2),
territory: zTerritory,
calendar: zProjectCalendar,
status: z.enum([
'in_draft',
'to_campaign_review',
'in_campaign_review',
'in_campaign_review.to_change',
'in_campaign_review.to_review',
'campaign_review.rejected',
'to_campaign',
'in_campaign',
'campaign.failed',
'campaign.cancelled',
'to_funding_review',
'in_funding_review',
'in_funding_review.to_change',
'in_funding_review.to_review',
'funding_review.rejected',
'to_funding',
'in_funding',
'funding.paid'
]).readonly().optional().default('in_draft')
});
/**
* Projects describe a User-owned, community-led event that is to be discovered, developed and funded by the community.
*/
@@ -981,7 +950,7 @@ export const zTerritoryJsonld = z.object({
]).optional(),
'@id': z.string().readonly().optional(),
'@type': z.string().readonly().optional(),
country: z.string().nullish(),
country: z.string().nullable(),
subLvl1: z.string().nullish(),
subLvl2: z.string().nullish(),
address: z.string().nullish()
@@ -1029,6 +998,37 @@ export const zMatchCallJsonld = z.object({
]).optional().default('in_editing')
});
/**
* Projects describe a User-owned, community-led event that is to be discovered, developed and funded by the community.
*/
export const zProjectProjectCreationDtoJsonld = z.object({
title: z.string().min(3).regex(/^(.*[a-zA-Z]{1,}.*)$/),
subtitle: z.string(),
categories: z.array(z.string()).min(1).max(2),
territory: zTerritoryJsonld,
calendar: zProjectCalendarJsonld,
status: z.enum([
'in_draft',
'to_campaign_review',
'in_campaign_review',
'in_campaign_review.to_change',
'in_campaign_review.to_review',
'campaign_review.rejected',
'to_campaign',
'in_campaign',
'campaign.failed',
'campaign.cancelled',
'to_funding_review',
'in_funding_review',
'in_funding_review.to_change',
'in_funding_review.to_review',
'funding_review.rejected',
'to_funding',
'in_funding',
'funding.paid'
]).readonly().optional().default('in_draft')
});
/**
* Unlike other Money recipients a Tipjar receives money with no further goal.\
* \
+9 -5
View File
@@ -5,21 +5,25 @@ import CreateProjectForm from "../../../components/create/CreateProjectForm.svel
import { apiCategoriesGetCollection } from "../../../openapi/client";
import { toCollectionItems } from "../../../utils/hydra";
import type { Category } from "../../../openapi/client";
import { goto } from "../../../utils/navigation";
import { withoutCache } from "../../../openapi/cacheInterceptor";
const { t, lang, langs, session } = Astro.locals;
if (!session) {
return Astro.redirect("/login");
return goto("/login", { query: { callback: "/create/project" } });
}
const { data: categoriesRaw } = await apiCategoriesGetCollection({
headers: { "Accept-Language": langs },
});
const { data: categoriesRaw } = await withoutCache(() =>
apiCategoriesGetCollection({
headers: { "Accept-Language": langs },
}),
);
const categories = toCollectionItems<Category>(categoriesRaw);
---
<Layout title={t("pages.project.create.title")}>
<App client:load locale={lang} session={session}>
<CreateProjectForm client:load categories={categories || []} />
<CreateProjectForm client:load {categories} />
</App>
</Layout>
-113
View File
@@ -1,113 +0,0 @@
import { z } from "zod";
import { maxEndDate } from "../../../utils/campaign";
const territorySchema = z
.object({
country: z.string().nullable().optional(),
subLvl1: z.string().nullable().optional(),
subLvl2: z.string().nullable().optional(),
address: z.string().nullable().optional(),
})
.refine(
(t) => !!(t.country || t.subLvl1 || t.subLvl2),
"validation.project.territory.required",
);
/**
* Validation schema for project creation form.
* Mirrors backend validation rules from Project.ProjectCreationDto.
*
* @see src/openapi/openapi.json - Project.ProjectCreationDto schema
*/
export const projectCreationSchema = z.object({
/**
* Project title - required field that must contain at least one letter.
* Pattern: ^(.*[a-zA-Z]{1,}.*)$
*/
title: z
.string()
.min(1, "system.validation.project.title.required")
.regex(/^(.*[a-zA-Z]{1,}.*)$/, "system.validation.project.title.pattern"),
/**
* Project subtitle - required field.
*/
subtitle: z.string().min(1, "system.validation.project.subtitle.required"),
/**
* Project categories - array with minimum 1 and maximum 2 items.
*/
categories: z
.array(z.string())
.min(1, "system.validation.project.categories.min")
.max(2, "system.validation.project.categories.max"),
/**
* Release date - must be at least 14 days from now.
* Defaults to 28 days from now.
* Accepts both Date objects and date strings (from HTML date inputs).
*/
release: z
.union([z.date(), z.string()])
.transform((val) => {
if (typeof val === "string") {
return new Date(val);
}
return val;
})
.refine(
(date) => {
const minDate = new Date();
minDate.setDate(minDate.getDate() + 14);
minDate.setHours(0, 0, 0, 0);
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
return normalizedDate >= minDate;
},
{ message: "system.validation.project.release.min" },
)
.refine(
(date) => {
// Release must not be past the platform-wide cutoff
// (PUBLIC_CAMPAIGN_MAX_END_DATE)
if (!maxEndDate) return true;
// Create a copy of the input date to avoid mutation
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
return normalizedDate <= maxEndDate;
},
{ message: "system.validation.project.release.max" },
)
.default(() => {
const defaultDate = new Date();
defaultDate.setDate(defaultDate.getDate() + 28);
return defaultDate;
}),
/**
* Project budget - optional field for display purposes.
* Not required for form submission but included for type consistency.
*/
budget: z.number().nonnegative().optional().default(0),
/**
* Plain-text address for the project location.
*/
address: z.string().min(1, "validation.project.address.required"),
/**
* ISO 3166 territory data derived from the selected address.
*/
territory: territorySchema,
});
/**
* TypeScript type inferred from the validation schema.
* Use this type for type-safe form data handling.
*/
export type ProjectCreationFormData = z.infer<typeof projectCreationSchema>;
+11 -8
View File
@@ -1,3 +1,5 @@
import type { Territory } from "../openapi/client";
export const NOMINATIM_ADDRESS = "https://nominatim.openstreetmap.org";
/**
@@ -73,14 +75,7 @@ export interface NominatimResult {
extratags?: Record<string, string>;
}
export interface ExtractedTerritory {
country: string | null;
subLvl1: string | null;
subLvl2: string | null;
address: string | null;
}
export function extractTerritory(result: NominatimResult): ExtractedTerritory {
export function extractTerritory(result: NominatimResult): Territory {
const address = result.address ?? {};
const country = address.country_code?.toUpperCase() ?? null;
@@ -98,6 +93,14 @@ export function extractTerritory(result: NominatimResult): ExtractedTerritory {
};
}
/**
* Perform a free-form search query against Nominatim. Requests are locally cached.
* @param value
* @param limit
* @param extratags
* @see https://nominatim.org/release-docs/develop/api/Search/
* @returns
*/
export async function searchPlace(
value: string,
limit: number = 6,
-291
View File
@@ -1,291 +0,0 @@
import murmur from "murmurhash-js";
import z from "zod";
import { type Draft, type Wizard, type CreateProjectForm } from "./projectDraft";
import { projectCreationSchema } from "../../pages/[...locale]/create/validation";
import { isPositiveMoney } from "../../utils/money";
import { richTextToPlainText } from "../../utils/richText";
import type {
ProjectBudgetItem,
ProjectCollaboration,
ProjectProjectCreationDto,
ProjectReward,
} from "../../openapi/client";
export type ValidationErrors = Record<string, string>;
/**
* Validates the entire create project form and updates the validation errors store.
* Returns true if the form is valid, false otherwise.
*/
export function validateCreateForm(createProject: ProjectProjectCreationDto): ValidationErrors {
const result = projectCreationSchema.safeParse(createProject);
if (!result.success) {
const errors: ValidationErrors = {};
result.error.issues.forEach((issue) => {
const fieldName = issue.path[0] as string;
if (!errors[fieldName]) {
errors[fieldName] = issue.message;
}
});
return errors;
}
return {};
}
export function validateDraftToPublish(draft: Draft): ValidationErrors {
const wizard = draft.wizardForm;
const errors: ValidationErrors = {};
Object.assign(errors, validateCreateForm(draft.createProject));
Object.assign(errors, validateConfiguration(wizard));
Object.assign(errors, validateCampaignInfo(wizard));
for (const reward of wizard.rewards) {
Object.assign(errors, validateReward(reward));
}
for (const collab of wizard.collaborations) {
Object.assign(errors, validateCollaboration(collab));
}
for (const minimumItem of wizard.budgetItems.minimum) {
Object.assign(errors, validateBudgetItem(minimumItem));
}
for (const optimumItem of wizard.budgetItems.optimum) {
Object.assign(errors, validateBudgetItem(optimumItem));
}
Object.assign(errors, validateBudgetAmount(draft));
return errors;
}
/**
* Validation schema for Configuration step
*/
export const configurationSchema = z.object({
deadline: z.enum(["minimum", "optimum"]),
});
/**
* Validate Configuration step (Step 1)
*
* Validates against the configurationSchema using Zod.
* Updates validationErrors store with any validation failures.
*
* @returns Record of field errors (empty if valid)
*/
export function validateConfiguration(wizard: Wizard): ValidationErrors {
const result = configurationSchema.safeParse(wizard.configuration);
if (!result.success) {
const errors: ValidationErrors = {};
result.error.issues.forEach((issue) => {
const fieldName = issue.path[0] as string;
if (!errors[fieldName]) {
errors[fieldName] = issue.message;
}
});
return errors;
}
return {};
}
/**
* Validate Campaign Info step (Step 2)
*
* Validates all campaign info fields according to requirements:
* - At least 1 image OR 1 video required
* - Objectives: min 50, max 5000 characters (plain text)
* - Legacy: min 50, max 5000 characters
* - Target audience: min 30, max 5000 characters
* - Team: min 50, max 5000 characters
*
* @returns Record of field errors (empty if valid)
*
* @example
* // Validate before completing step
* function handleContinue() {
* const errors = validateCampaignInfo();
* if (Object.keys(errors).length === 0) {
* completeCurrentStep();
* }
* }
*/
export function validateCampaignInfo(wizard: Wizard): ValidationErrors {
const data = wizard.campaignInfo;
const errors: ValidationErrors = {};
// Media validation
if (data.images.length === 0 && !data.video) {
errors.media = "pages.project.edit.rewards.validation_info.reward.media.required";
}
// Objectives validation
const objectivesPlainText = richTextToPlainText(data.brief).trim();
if (objectivesPlainText.length === 0) {
errors.objectives = "pages.project.edit.rewards.validation_info.reward.objectives.required";
} else if (objectivesPlainText.length < 50) {
errors.objectives =
"pages.project.edit.rewards.validation_info.reward.objectives.min_length";
} else if (objectivesPlainText.length > 5000) {
errors.objectives =
"pages.project.edit.rewards.validation_info.reward.objectives.max_length";
}
// Legacy validation
const legacyPlainText = richTextToPlainText(data.about).trim();
if (legacyPlainText.length === 0) {
errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.required";
} else if (legacyPlainText.length < 50) {
errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.min_length";
} else if (legacyPlainText.length > 5000) {
errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.max_length";
}
// Target audience validation
const targetPlainText = richTextToPlainText(data.goal).trim();
if (targetPlainText.length === 0) {
errors.targetAudience = "pages.project.edit.rewards.validation_info.reward.target.required";
} else if (targetPlainText.length < 30) {
errors.targetAudience =
"pages.project.edit.rewards.validation_info.reward.target.min_length";
} else if (targetPlainText.length > 5000) {
errors.targetAudience =
"pages.project.edit.rewards.validation_info.reward.target.max_length";
}
// Team validation
const teamPlainText = richTextToPlainText(data.team).trim();
if (teamPlainText.length === 0) {
errors.team = "pages.project.edit.rewards.validation_info.reward.team.required";
} else if (teamPlainText.length < 50) {
errors.team = "pages.project.edit.rewards.validation_info.reward.team.min_length";
} else if (teamPlainText.length > 5000) {
errors.team = "pages.project.edit.rewards.validation_info.reward.team.max_length";
}
return errors;
}
export function validateReward(reward: ProjectReward): ValidationErrors {
const errors: ValidationErrors = {};
const hash = murmur.murmur3(JSON.stringify(reward));
if (!reward.title.trim()) {
errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title";
}
if (!isPositiveMoney(reward.money)) {
errors[`reward_error_amount_${hash}`] = "pages.project.edit.rewards.validation.amount";
}
if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) {
errors[`reward_error_units_${hash}`] = "pages.project.edit.rewards.validation.units";
}
return errors;
}
export function validateCollaboration(collab: ProjectCollaboration): ValidationErrors {
const errors: ValidationErrors = {};
const hash = murmur.murmur3(JSON.stringify(collab));
if (!collab.title.trim()) {
errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title";
}
if (collab.description && collab.description.length > 1000) {
errors[`collab_error_description_too_long_${hash}`] =
"pages.project.edit.collaborations.validation.descriptionTooLong";
}
if (!collab.description.trim()) {
errors[`collab_error_description_${hash}`] =
"pages.project.edit.collaborations.validation.description";
}
return errors;
}
export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors {
const errors: ValidationErrors = {};
const hash = murmur.murmur3(JSON.stringify(item));
if (!item.title.trim()) {
errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title.";
}
if (!item.description.trim()) {
errors[`budget_error_description_${hash}`] =
"pages.project.edit.budget.validation.description";
}
if (!isPositiveMoney(item.money)) {
errors[`budget_error_amount_${hash}`] = "pages.project.edit.budget.validation.amount";
}
if (!item.money.currency) {
errors[`budget_error_currency_${hash}`] = "pages.project.edit.budget.validation.currency";
}
if (!item.type) {
errors[`budget_error_type_${hash}`] = "pages.project.edit.budget.validation.type";
}
if (!item.deadline || (item.deadline !== "minimum" && item.deadline !== "optimum")) {
errors[`budget_error_deadline_${hash}`] = "pages.project.edit.budget.validation.class";
}
return errors;
}
export function validateBudgetAmount(draft: Draft) {
const budgetItems = draft.wizardForm.budgetItems;
const errors: ValidationErrors = {};
if (budgetItems.minimum.length <= 0) {
errors.minimum_length = "pages.project.edit.budget.validation.minimumItemsLength";
}
return errors;
}
/**
* Validates a single field and updates the validation errors store.
* Uses Zod's type system with proper type guards to avoid unsafe type assertions.
* @param fieldName - The name of the field to validate
* @param value - The current value of the field
*/
export function validateField(fieldName: keyof CreateProjectForm, value: unknown): string | null {
type SchemaFields = keyof typeof projectCreationSchema.shape;
if (!(fieldName in projectCreationSchema.shape)) {
return null;
}
const fieldSchema = projectCreationSchema.shape[fieldName as SchemaFields];
const result = (fieldSchema as z.ZodTypeAny).safeParse(value);
if (!result.success) {
return result.error.issues[0]?.message || "";
}
return null;
}
-35
View File
@@ -1,35 +0,0 @@
/**
* Parses a recurring DD-MM string into the next upcoming local-midnight Date.
* The year is implicit: the cutoff recurs every year, so the value resolves to
* the next occurrence of that day/month on or after today.
* Returns undefined for empty or malformed values.
*/
export function parseMaxEndDate(raw: string | undefined, now: Date = new Date()): Date | undefined {
if (!raw || raw === "") return undefined;
const match = /^(\d{2})-(\d{2})$/.exec(raw);
if (!match) {
console.warn(`Invalid PUBLIC_CAMPAIGN_MAX_END_DATE value (expected DD-MM): ${raw}`);
return undefined;
}
const day = Number(match[1]);
const month = Number(match[2]);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const cutoff = new Date(today.getFullYear(), month - 1, day);
// Already passed this year → use next year's occurrence
if (cutoff < today) {
cutoff.setFullYear(cutoff.getFullYear() + 1);
}
return cutoff;
}
/**
* Latest date a project may start its campaign, as configured by the platform
* owner. PUBLIC_CAMPAIGN_MAX_END_DATE is a recurring DD-MM value (year implicit);
* it resolves to the next upcoming occurrence. Leave empty to disable the limit.
*/
export const maxEndDate = parseMaxEndDate(import.meta.env.PUBLIC_CAMPAIGN_MAX_END_DATE);
+65
View File
@@ -9,3 +9,68 @@ export function formatDate(date: Date, locale: Locale) {
return date.toLocaleDateString(locale, options);
}
/**
* Get a date `n` ms from now
* @param offset Milliseconds since now until future date
* @returns {Date}
*/
export function getDateInFuture(offset: number): Date {
const now = new Date();
now.setTime(now.getTime() + offset);
return now;
}
/**
* Parses an ISO-like date string. Replaces partial values and wildcards `*` with the appropriate current value.
* @param raw
* @param now
* @returns
*/
export function parseDate(raw: string | undefined, now: Date = new Date()): Date | undefined {
if (!raw) return undefined;
const parts = raw.split("-");
if (
parts.length < 1 ||
parts.length > 3 ||
!/^(?:\d{4}|\*)$/.test(parts[0]) ||
parts.slice(1).some((part) => !/^(?:\d{2}|\*)$/.test(part))
) {
console.warn(`Invalid value: ${raw}`);
return undefined;
}
const year = parts[0] === "*" ? now.getFullYear() : Number(parts[0]);
const month =
parts[1] === undefined || parts[1] === "*" ? now.getMonth() + 1 : Number(parts[1]);
const day = parts[2] === undefined || parts[2] === "*" ? now.getDate() : Number(parts[2]);
return new Date(year, month - 1, day);
}
/**
* Latest date a project may start its campaign, as configured by the platform owner.
* Calculated from `PUBLIC_CAMPAIGN_MAX_END_DATE`. Leave empty to disable the limit.
*/
export const CAMPAIGN_MAX_END_DATE = parseDate(import.meta.env.PUBLIC_CAMPAIGN_MAX_END_DATE);
function getMinStartDate() {
const minStartDate = getDateInFuture(
86400000 * parseInt(import.meta.env.PUBLIC_CAMPAIGN_MIN_REV_DAYS || 1),
);
minStartDate.setHours(0, 0, 0);
return minStartDate;
}
/**
* Earliest date a project may start its campaign, as calculated from the platform's minimum review days.
* Calculated from `PUBLIC_CAMPAIGN_MIN_REV_DAYS`. When empty will default to 1 day from now.
*/
export const CAMPAIGN_MIN_START_DATE = getMinStartDate();
+22
View File
@@ -0,0 +1,22 @@
import iso3166 from "iso-3166-2";
import type { Territory } from "../openapi/client";
export function getTerritoryDisplayName(territory: Territory, lang: Intl.LocalesArgument): string {
const countryNames = new Intl.DisplayNames(lang, { type: "region" });
const country = countryNames.of(territory.country!);
const tag = territory.subLvl2 ?? territory.subLvl1;
if (!tag) {
return country!;
}
const iso = iso3166.subdivision(tag!);
if (!iso || !iso.name) {
return country!;
}
const subdivision = iso.name.split(",")[0];
return `${subdivision}, ${country}`;
}
+3 -1
View File
@@ -24,7 +24,9 @@ export function validate<T>(value: unknown, schema: z.ZodType<T>): ValidationErr
}));
}
function getValidationParams(issue: z.core.$ZodIssue): Record<string, string | number> | undefined {
export function getValidationParams(
issue: z.core.$ZodIssue,
): Record<string, string | number> | undefined {
switch (issue.code) {
case "too_small":
return { min: Number(issue.minimum) };
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod";
import { zProjectProjectCreationDto } from "../openapi/client/zod.gen";
import { CAMPAIGN_MAX_END_DATE, CAMPAIGN_MIN_START_DATE } from "../utils/dates";
export const zProjectCampaignRelease = z
.date()
.refine((release) => release >= CAMPAIGN_MIN_START_DATE, {
error: "system.validation.project.release.min",
params: { min: CAMPAIGN_MIN_START_DATE },
})
.refine((release) => CAMPAIGN_MAX_END_DATE && release <= CAMPAIGN_MAX_END_DATE, {
error: "system.validation.project.release.max",
params: { max: CAMPAIGN_MAX_END_DATE },
});
export const zCreateProjectForm = zProjectProjectCreationDto.superRefine((form, ctx) => {
if (!form.calendar?.release) {
return;
}
const release = zProjectCampaignRelease.safeParse(new Date(form.calendar.release));
if (!release.success) {
for (const issue of release.error.issues) {
ctx.addIssue({
...issue,
path: ["calendar"],
});
}
}
});