Merge branch 'develop' of github.com:goteo/org.goteo.www into feat/home-banners-real-data
This commit is contained in:
+3
-5
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+13
-11
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }}",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.\
|
||||
* \
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user