Merge remote-tracking branch 'origin/develop' into fix/update-community-card
This commit is contained in:
+5
-3
@@ -3,13 +3,15 @@ name: org.goteo.www
|
||||
description: Provides a web GUI to interact with the Goteo v4 API and adds tools for implementing the crowdfunding platform Goteo as an open-core application. Use it when writing or reviewing source code for the Goteo v4 web.
|
||||
---
|
||||
|
||||
# Goteo v4 Web Codebase Reference
|
||||
|
||||
## Stack
|
||||
|
||||
Astro 5 + Svelte 5 + TailwindCSS 4 + TypeScript 5, deployed on Cloudflare Workers (also supports Node adapter).
|
||||
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
```bash
|
||||
src/
|
||||
├── actions/ # Astro server actions (form mutations)
|
||||
├── auth/ # JWT sessions, OAuth2 tokens, refresh logic
|
||||
@@ -491,7 +493,7 @@ Two packages: `@hey-api/openapi-ts` (codegen, dev dependency) and `@hey-api/clie
|
||||
|
||||
SDK functions follow the pattern `api{Resource}{Operation}`:
|
||||
|
||||
```
|
||||
```bash
|
||||
apiProjectsGetCollection() → GET /v4/projects
|
||||
apiProjectsIdOrSlugGet() → GET /v4/projects/{idOrSlug}
|
||||
apiUsersPost() → POST /v4/users
|
||||
@@ -500,7 +502,7 @@ apiUsersIdpersonPatch() → PATCH /v4/users/{id}/person
|
||||
|
||||
Path constants (from `paths.gen.ts`) follow the same pattern with `Url` suffix:
|
||||
|
||||
```
|
||||
```bash
|
||||
apiProjectsIdOrSlugGetUrl → '/v4/projects/{idOrSlug}'
|
||||
apiAccountingsIdGetUrl → '/v4/accountings/{id}'
|
||||
```
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /src/i18n/locales/es.json
|
||||
translation: /src/i18n/locales/%two_letters_code%.json
|
||||
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /src/i18n/locales/es.json
|
||||
translation: /src/i18n/locales/%two_letters_code%.json
|
||||
@@ -45,6 +45,7 @@
|
||||
"bits-ui": "^2.18.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"csrf": "^3.1.0",
|
||||
"dexie": "^4.4.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"file-type": "^22.0.1",
|
||||
"flickity": "^3.0.0",
|
||||
|
||||
Generated
+1499
-1765
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,35 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { t } from "../../i18n/store";
|
||||
import Button from "../library/Button.svelte";
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="flex flex-row gap-6">
|
||||
<button
|
||||
class="btn btn-primary text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
|
||||
<div class="flex flex-row flex-wrap gap-6">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
class="min-h-10 rounded-[18px] bg-transparent px-4 py-2 text-base leading-5"
|
||||
>
|
||||
{$t("contributions.grid.actions.refundToWallet")}</button
|
||||
{$t("pages.admin.charges.actions.refundToWallet")}</Button
|
||||
>
|
||||
<button
|
||||
class="btn btn-secondary text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
class="min-h-10 rounded-[18px] bg-transparent px-4 py-2 text-base leading-5"
|
||||
>
|
||||
{$t("contributions.grid.actions.refundToUser")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
|
||||
>{$t("contributions.grid.actions.changeUser")}</button
|
||||
{$t("pages.admin.charges.actions.refundToUser")}
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
class="min-h-10 rounded-[18px] bg-transparent px-4 py-2 text-base leading-5"
|
||||
>{$t("pages.admin.charges.actions.changeUser")}</Button
|
||||
>
|
||||
<button
|
||||
class="btn btn-danger text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
|
||||
>{$t("contributions.grid.actions.manageReward")}
|
||||
</button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
class="min-h-10 rounded-[18px] bg-transparent px-4 py-2 text-base leading-5"
|
||||
>{$t("pages.admin.charges.actions.manageReward")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,12 +28,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<section
|
||||
class="flex flex-col gap-6 text-base leading-5 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="text-content min-h-5">
|
||||
{#if date && time}
|
||||
<p>
|
||||
{@html $t(
|
||||
"contributions.grid.content-footer.lastEdited",
|
||||
"pages.admin.charges.lastEdited",
|
||||
{
|
||||
date: `<span class="font-bold">${date}</span>`,
|
||||
time: `<span class="font-bold">${time}</span>`,
|
||||
@@ -43,17 +45,20 @@
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<p class="text-secondary font-bold">
|
||||
{$t("contributions.grid.content-footer.downloadDetails")}
|
||||
</p>
|
||||
<span
|
||||
class="text-secondary bg-variant1 flex flex-row items-center gap-2 rounded-2xl px-4 py-2 font-bold"
|
||||
<div class="flex flex-row flex-wrap items-center justify-start gap-8 md:justify-end">
|
||||
<button class="text-secondary cursor-pointer bg-transparent font-bold">
|
||||
{$t("pages.admin.charges.downloadButtons.certificates")}
|
||||
</button>
|
||||
<button class="text-secondary cursor-pointer bg-transparent font-bold">
|
||||
{$t("pages.admin.charges.downloadButtons.pdf")}
|
||||
</button>
|
||||
<button
|
||||
class="text-secondary bg-variant1 flex min-h-10 cursor-pointer flex-row items-center gap-2 rounded-2xl px-4 py-2 font-bold"
|
||||
>
|
||||
<AnnotationIcon />
|
||||
{$t("contributions.grid.content-footer.annotations", {
|
||||
<AnnotationIcon size={20} class="shrink-0" />
|
||||
{$t("pages.admin.charges.annotations", {
|
||||
annotations: 0,
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
import FiltersTags from "./FiltersTags.svelte";
|
||||
import Slider from "./Slider.svelte";
|
||||
import Table, { type ExtendedCharge } from "./Table.svelte";
|
||||
import { session } from "../../auth/store";
|
||||
import { t } from "../../i18n/store";
|
||||
import { client } from "../../openapi/client/client.gen";
|
||||
import {
|
||||
apiAccountingsIdGet,
|
||||
apiGatewayChargesGetCollection,
|
||||
apiGatewayCheckoutsIdGet,
|
||||
apiGatewaysGetCollection,
|
||||
apiProjectsIdOrSlugGet,
|
||||
apiTipjarsIdGet,
|
||||
apiUsersIdOrHandleGet,
|
||||
type Accounting,
|
||||
type ApiGatewayChargesGetCollectionData,
|
||||
type GatewayCharge,
|
||||
@@ -20,7 +26,6 @@
|
||||
type User,
|
||||
} from "../../openapi/client/index.ts";
|
||||
import {
|
||||
apiGatewayChargesGetCollectionUrl,
|
||||
apiProjectsGetCollectionUrl,
|
||||
apiTipjarsGetCollectionUrl,
|
||||
apiUsersGetCollectionUrl,
|
||||
@@ -32,22 +37,8 @@
|
||||
currentPage,
|
||||
sortOptions,
|
||||
} from "../../stores/chargesPaginationAndSort.ts";
|
||||
import {
|
||||
fetchAccounting,
|
||||
fetchCheckout,
|
||||
fetchProject,
|
||||
fetchTipjar,
|
||||
fetchUser,
|
||||
fetchWithPersistentCache,
|
||||
} from "../../utils/cachedFetch";
|
||||
import { extractId } from "../../utils/extractId";
|
||||
|
||||
type GatewayChargesCollection<T> = {
|
||||
member: T[];
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
const API_CACHE_NAME = "charges-cache";
|
||||
import { toCollectionItems } from "../../utils/hydra";
|
||||
|
||||
let filters: ApiGatewayChargesGetCollectionData["query"] = $state({});
|
||||
|
||||
@@ -61,19 +52,6 @@
|
||||
let isFirstLoad = $state(true);
|
||||
let selectedSort = $state("date-desc");
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|;\s*)access-token=([^;]*)/);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(match[1]);
|
||||
const parsed = JSON.parse(decoded);
|
||||
return parsed?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildChargesQuery(
|
||||
filters: ApiGatewayChargesGetCollectionData["query"],
|
||||
page: number,
|
||||
@@ -94,6 +72,103 @@
|
||||
return query;
|
||||
}
|
||||
|
||||
function getCollectionTotalItems(collection: unknown): number {
|
||||
if (Array.isArray(collection)) return collection.length;
|
||||
if (!collection || typeof collection !== "object") return 0;
|
||||
|
||||
const record = collection as Record<string, unknown>;
|
||||
const totalItems = record.totalItems ?? record["hydra:totalItems"];
|
||||
|
||||
return typeof totalItems === "number"
|
||||
? totalItems
|
||||
: toCollectionItems<GatewayCharge>(collection).length;
|
||||
}
|
||||
|
||||
async function fetchCheckout(iri: string | undefined, headers: HeadersInit | undefined) {
|
||||
const id = extractId(iri);
|
||||
if (!id) return;
|
||||
|
||||
const { data, error } = await apiGatewayCheckoutsIdGet({
|
||||
path: { id },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to fetch checkout ${iri}:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchAccounting(iri: string | undefined, headers: HeadersInit | undefined) {
|
||||
const id = extractId(iri);
|
||||
if (!id) return;
|
||||
|
||||
const { data, error } = await apiAccountingsIdGet({
|
||||
path: { id },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to fetch accounting ${iri}:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchUser(iri: string | undefined, headers: HeadersInit | undefined) {
|
||||
const idOrHandle = extractId(iri);
|
||||
if (!idOrHandle) return;
|
||||
|
||||
const { data, error } = await apiUsersIdOrHandleGet({
|
||||
path: { idOrHandle },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to fetch user ${iri}:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchProject(iri: string | undefined, headers: HeadersInit | undefined) {
|
||||
const idOrSlug = extractId(iri);
|
||||
if (!idOrSlug) return;
|
||||
|
||||
const { data, error } = await apiProjectsIdOrSlugGet({
|
||||
path: { idOrSlug },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to fetch project ${iri}:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchTipjar(iri: string | undefined, headers: HeadersInit | undefined) {
|
||||
const id = extractId(iri);
|
||||
if (!id) return;
|
||||
|
||||
const { data, error } = await apiTipjarsIdGet({
|
||||
path: { id },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to fetch tipjar ${iri}:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadCharges(
|
||||
filters: ApiGatewayChargesGetCollectionData["query"],
|
||||
): Promise<
|
||||
@@ -108,44 +183,38 @@
|
||||
const owners: Map<string, User | Project | Tipjar> = new Map();
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
let page = $currentPage;
|
||||
let items = Number($itemsPerPage);
|
||||
|
||||
const query = buildChargesQuery(filters, page, items);
|
||||
const headers = $session?.token.asHttpHeaders as HeadersInit | undefined;
|
||||
|
||||
const collection = await fetchWithPersistentCache<
|
||||
GatewayChargesCollection<GatewayCharge>
|
||||
>(
|
||||
client.buildUrl({
|
||||
url: apiGatewayChargesGetCollectionUrl,
|
||||
query,
|
||||
}),
|
||||
token,
|
||||
API_CACHE_NAME,
|
||||
);
|
||||
const { data: collection, error } = await apiGatewayChargesGetCollection({
|
||||
query,
|
||||
headers,
|
||||
});
|
||||
|
||||
const loadedCharges = collection.member ?? [];
|
||||
$totalItems = collection.totalItems ?? 0;
|
||||
if (error) {
|
||||
console.error("Failed to fetch gateway charges:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedCharges = toCollectionItems<GatewayCharge>(collection);
|
||||
$totalItems = getCollectionTotalItems(collection);
|
||||
|
||||
for (const charge of loadedCharges) {
|
||||
const checkoutIri = charge.checkout;
|
||||
const targetAccountingIri = charge.target;
|
||||
|
||||
if (checkoutIri && !checkouts.has(checkoutIri)) {
|
||||
checkouts.set(
|
||||
checkoutIri,
|
||||
await Promise.resolve(fetchCheckout(checkoutIri, token, API_CACHE_NAME)),
|
||||
);
|
||||
checkouts.set(checkoutIri, await fetchCheckout(checkoutIri, headers));
|
||||
|
||||
const originAccountingIri = checkouts.get(checkoutIri)?.origin;
|
||||
|
||||
if (originAccountingIri && !accountings.has(originAccountingIri)) {
|
||||
await preloadAccountingData(
|
||||
originAccountingIri,
|
||||
token,
|
||||
headers,
|
||||
accountings,
|
||||
owners,
|
||||
);
|
||||
@@ -153,7 +222,7 @@
|
||||
}
|
||||
|
||||
if (targetAccountingIri && !accountings.has(targetAccountingIri)) {
|
||||
await preloadAccountingData(targetAccountingIri, token, accountings, owners);
|
||||
await preloadAccountingData(targetAccountingIri, headers, accountings, owners);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +234,7 @@
|
||||
checkoutOrigin: checkout?.origin ?? "—",
|
||||
paymentMethod: extractId(checkout?.gateway) ?? "—",
|
||||
refundToWallet: checkout?.refund
|
||||
? $t(`contributions.table.rows.refund.${checkout.refund}`)
|
||||
? $t(`domain.charges.refund.${checkout.refund}`)
|
||||
: "—",
|
||||
platformLinks: checkout?.links ?? [],
|
||||
trackingCodes: checkout?.trackings ?? [],
|
||||
@@ -195,7 +264,7 @@
|
||||
|
||||
async function resolveOwner(
|
||||
ownerIri: string,
|
||||
token: string,
|
||||
headers: HeadersInit | undefined,
|
||||
owners: Map<string, User | Project | Tipjar>,
|
||||
) {
|
||||
if (owners.has(ownerIri)) return;
|
||||
@@ -204,19 +273,19 @@
|
||||
|
||||
if (!handler) return;
|
||||
|
||||
const entity = await handler.fetcher(ownerIri, token, API_CACHE_NAME);
|
||||
const entity = await handler.fetcher(ownerIri, headers);
|
||||
if (entity) owners.set(ownerIri, entity);
|
||||
}
|
||||
|
||||
async function preloadAccountingData(
|
||||
accountingIri: string | null,
|
||||
token: string,
|
||||
headers: HeadersInit | undefined,
|
||||
accountings: Map<string, Accounting>,
|
||||
owners: Map<string, User | Project | Tipjar>,
|
||||
) {
|
||||
if (!accountingIri || accountings.has(accountingIri)) return;
|
||||
|
||||
const accounting = await fetchAccounting(accountingIri, token, API_CACHE_NAME);
|
||||
const accounting = await fetchAccounting(accountingIri, headers);
|
||||
if (!accounting) return;
|
||||
|
||||
accountings.set(accountingIri, accounting);
|
||||
@@ -224,7 +293,7 @@
|
||||
const ownerIri = accounting.owner;
|
||||
if (!ownerIri) return;
|
||||
|
||||
await resolveOwner(ownerIri, token, owners);
|
||||
await resolveOwner(ownerIri, headers, owners);
|
||||
}
|
||||
|
||||
function handleApplyFilters(newFilters: ApiGatewayChargesGetCollectionData["query"]) {
|
||||
@@ -249,15 +318,15 @@
|
||||
const { data: paymentGateways } = await apiGatewaysGetCollection();
|
||||
|
||||
paymentMethodOptions = [
|
||||
["all", $t("contributions.filters.paymentMethod.options.all")],
|
||||
["all", $t("pages.admin.charges.filters.paymentMethod.options.all")],
|
||||
...(paymentGateways ?? []).map((g): [string, string] => [
|
||||
g.name!,
|
||||
$t(`contributions.filters.paymentMethod.options.${g.name}`),
|
||||
$t(`pages.admin.charges.filters.paymentMethod.options.${g.name}`),
|
||||
]),
|
||||
];
|
||||
|
||||
chargeStatusOptions = Object.entries($t("contributions.filters.chargeStatus.options"));
|
||||
rangeAmountOptions = Object.entries($t("contributions.filters.rangeAmount.options")).sort(
|
||||
chargeStatusOptions = Object.entries($t("pages.admin.charges.filters.chargeStatus.options"));
|
||||
rangeAmountOptions = Object.entries($t("pages.admin.charges.filters.rangeAmount.options")).sort(
|
||||
([a], [b]) => {
|
||||
const parseMin = (val: string) =>
|
||||
val.includes("..") ? parseInt(val.split("..")[0]) : parseInt(val);
|
||||
@@ -281,7 +350,7 @@
|
||||
<div class="mb-8 flex justify-between">
|
||||
<FiltersTags
|
||||
onCloseFilter={handleApplyFilters}
|
||||
title={$t("admin.charges.lastContributions")}
|
||||
title={$t("domain.charges.lastContributions")}
|
||||
{filters}
|
||||
/>
|
||||
<ExportCsv {filters} />
|
||||
|
||||
@@ -14,34 +14,46 @@
|
||||
import Tooltip from "./Tooltip.svelte";
|
||||
import { t } from "../../i18n/store";
|
||||
import Copy from "../icons/Copy.svelte";
|
||||
import Grid from "../library/Grid.svelte";
|
||||
|
||||
import type { Tracking, Link } from "../../../src/openapi/client/index.ts";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const fallbackDate = {
|
||||
date: "—",
|
||||
time: "—",
|
||||
fulltime: "—",
|
||||
};
|
||||
|
||||
const {
|
||||
id,
|
||||
trackingCodes,
|
||||
dataTimeCreated,
|
||||
dataTimeUpdated,
|
||||
platformLinks,
|
||||
refundToWallet,
|
||||
concept,
|
||||
id = "-",
|
||||
trackingCodes = [],
|
||||
dataTimeCreated = fallbackDate,
|
||||
dataTimeUpdated = fallbackDate,
|
||||
platformLinks = [],
|
||||
refundToWallet = "—",
|
||||
concept = "",
|
||||
details,
|
||||
actions,
|
||||
footer,
|
||||
} = $props<{
|
||||
id: string;
|
||||
trackingCodes: Tracking[];
|
||||
dataTimeCreated: {
|
||||
id?: string;
|
||||
trackingCodes?: Tracking[];
|
||||
dataTimeCreated?: {
|
||||
date: string;
|
||||
time: string;
|
||||
fulltime: string;
|
||||
};
|
||||
dataTimeUpdated: {
|
||||
dataTimeUpdated?: {
|
||||
date: string;
|
||||
time: string;
|
||||
fulltime: string;
|
||||
};
|
||||
platformLinks: Link[];
|
||||
refundToWallet: string;
|
||||
concept: string;
|
||||
platformLinks?: Link[];
|
||||
refundToWallet?: string;
|
||||
concept?: string;
|
||||
details?: Snippet;
|
||||
actions?: Snippet;
|
||||
footer?: Snippet;
|
||||
}>();
|
||||
|
||||
let trackingModal = $state(false);
|
||||
@@ -67,195 +79,208 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-10">
|
||||
<Grid class="text-content grid-cols-4 gap-x-8 gap-y-8 rounded-md bg-[#fbfafe] p-6 text-base">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold">{$t("contributions.grid.details.operationTime")}</p>
|
||||
<span title={dataTimeCreated.fulltime}>{dataTimeCreated.time}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold">{$t("contributions.grid.details.trackingCodes.title")}</p>
|
||||
<button
|
||||
class="text-secondary flex cursor-pointer items-start truncate whitespace-nowrap underline"
|
||||
title={trackingCodes.map((tc: Tracking) => tc.value).join(", ")}
|
||||
onclick={() => (trackingModal = true)}
|
||||
>
|
||||
<span class="truncate">{trackingCodes[0]?.value ?? "—"}</span>
|
||||
<span> ({trackingCodes.length})</span>
|
||||
</button>
|
||||
<section class="flex flex-col gap-10 px-10 py-8">
|
||||
{#if details}
|
||||
{@render details()}
|
||||
{:else}
|
||||
<div
|
||||
class="text-content grid grid-cols-1 gap-x-10 gap-y-8 text-base leading-5 sm:grid-cols-2 xl:grid-cols-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="font-bold">{$t("pages.admin.charges.details.operationTime")}</p>
|
||||
<span title={dataTimeCreated.fulltime}>{dataTimeCreated.time}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="font-bold">{$t("pages.admin.charges.details.trackingCodes.title")}</p>
|
||||
<button
|
||||
class="text-secondary flex cursor-pointer items-start truncate whitespace-nowrap underline"
|
||||
title={trackingCodes.map((tc: Tracking) => tc.value).join(", ")}
|
||||
onclick={() => (trackingModal = true)}
|
||||
>
|
||||
<span class="truncate">{trackingCodes[0]?.value ?? "—"}</span>
|
||||
<span> ({trackingCodes.length})</span>
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
bind:open={trackingModal}
|
||||
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="left-1/2! max-w-200 p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
|
||||
title={$t("contributions.grid.details.trackingCodes.title")}
|
||||
headerClass="py-2"
|
||||
>
|
||||
<Table class="w-full table-fixed border-separate border-spacing-y-2">
|
||||
<TableHead>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("contributions.grid.details.trackingCodes.headers.title")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("contributions.grid.details.trackingCodes.headers.trackingCode")}
|
||||
</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody class="text-base">
|
||||
{#each trackingCodes as item}
|
||||
<TableBodyRow class=" bg-white">
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-l-md border-t border-b border-l"
|
||||
>{item.title}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-r-md border-t border-r border-b align-top"
|
||||
>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div
|
||||
class="w-full leading-snug break-all whitespace-normal"
|
||||
style="word-break: break-word;"
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
<Tooltip
|
||||
text={$t("contributions.tootip.copied")}
|
||||
tooltipClass="bg-secondary -translate-x-[90%]"
|
||||
className="size-5 cursor-copy shrink-0"
|
||||
>
|
||||
<button
|
||||
id={`copy-${item.value}`}
|
||||
type="button"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(item.value)}
|
||||
<Modal
|
||||
bind:open={trackingModal}
|
||||
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="left-1/2! max-w-200 p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
|
||||
title={$t("pages.admin.charges.details.trackingCodes.title")}
|
||||
headerClass="py-2"
|
||||
>
|
||||
<Table class="w-full table-fixed border-separate border-spacing-y-2">
|
||||
<TableHead>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("pages.admin.charges.details.trackingCodes.headers.title")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t(
|
||||
"pages.admin.charges.details.trackingCodes.headers.trackingCode",
|
||||
)}
|
||||
</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody class="text-base">
|
||||
{#each trackingCodes as item}
|
||||
<TableBodyRow class=" bg-white">
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-l-md border-t border-b border-l"
|
||||
>{item.title}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-r-md border-t border-r border-b align-top"
|
||||
>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div
|
||||
class="w-full leading-snug break-all whitespace-normal"
|
||||
style="word-break: break-word;"
|
||||
>
|
||||
<Copy />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold">{$t("contributions.grid.details.toWallet")}</p>
|
||||
<p>{refundToWallet}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold"></p>
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold">{$t("contributions.grid.details.platformLinks.title")}</p>
|
||||
|
||||
<button
|
||||
class="text-secondary flex cursor-pointer items-start truncate whitespace-nowrap underline"
|
||||
title={platformLinks
|
||||
.map((pl: Link) => pl.href ?? "")
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
onclick={() => (linksModal = true)}
|
||||
>
|
||||
<span class="truncate">
|
||||
{platformLinks.find((pl: Link) => pl.type === "payment")?.href ??
|
||||
platformLinks[0]?.href ??
|
||||
"—"}
|
||||
</span>
|
||||
<span> ({platformLinks.length})</span>
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
bind:open={linksModal}
|
||||
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="left-1/2! max-w-200 p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
|
||||
title={$t("contributions.grid.details.platformLinks.title")}
|
||||
headerClass="py-2"
|
||||
>
|
||||
<Table class="w-full table-fixed border-separate border-spacing-y-2">
|
||||
<TableHead>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("contributions.grid.details.platformLinks.headers.type")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary border-t-lg border-b-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("contributions.grid.details.platformLinks.headers.rel")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("contributions.grid.details.platformLinks.headers.href")}
|
||||
</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody class="text-base">
|
||||
{#each platformLinks as item}
|
||||
<TableBodyRow class=" bg-white">
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-l-md border-t border-b border-l"
|
||||
>
|
||||
{item.type}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-l-md border-t border-b"
|
||||
>
|
||||
{item.rel}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-r-md border-t border-r border-b align-top"
|
||||
>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div
|
||||
class=" w-full cursor-pointer leading-snug break-all whitespace-normal"
|
||||
style="word-break: break-word; text-decoration-line: underline;"
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
class=" text-secondary"
|
||||
target="_blank"
|
||||
>{item.href}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
text={$t("contributions.tootip.copied")}
|
||||
tooltipClass="bg-secondary"
|
||||
className="size-5 cursor-copy shrink-0"
|
||||
>
|
||||
<button
|
||||
id={`copy-${item.href}`}
|
||||
type="button"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(item.href)}
|
||||
{item.value}
|
||||
</div>
|
||||
<Tooltip
|
||||
text={$t("common.tooltip.coved")}
|
||||
tooltipClass="bg-secondary -translate-x-[90%]"
|
||||
className="size-5 cursor-copy shrink-0"
|
||||
>
|
||||
<Copy />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
</div>
|
||||
<button
|
||||
id={`copy-${item.value}`}
|
||||
type="button"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(item.value)}
|
||||
>
|
||||
<Copy />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="font-bold">{$t("pages.admin.charges.details.toWallet")}</p>
|
||||
<p>{refundToWallet}</p>
|
||||
</div>
|
||||
<div class="hidden xl:block"></div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold">{$t("contributions.grid.details.estimatedFee")}</p>
|
||||
<p>—</p>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="font-bold">{$t("pages.admin.charges.details.platformLinks.title")}</p>
|
||||
|
||||
<button
|
||||
class="text-secondary flex cursor-pointer items-start truncate whitespace-nowrap underline"
|
||||
title={platformLinks
|
||||
.map((pl: Link) => pl.href ?? "")
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
onclick={() => (linksModal = true)}
|
||||
>
|
||||
<span class="truncate">
|
||||
{platformLinks.find((pl: Link) => pl.type === "payment")?.href ??
|
||||
platformLinks[0]?.href ??
|
||||
"—"}
|
||||
</span>
|
||||
<span> ({platformLinks.length})</span>
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
bind:open={linksModal}
|
||||
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="left-1/2! max-w-200 p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
|
||||
title={$t("pages.admin.charges.details.platformLinks.title")}
|
||||
headerClass="py-2"
|
||||
>
|
||||
<Table class="w-full table-fixed border-separate border-spacing-y-2">
|
||||
<TableHead>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("pages.admin.charges.details.platformLinks.headers.type")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary border-t-lg border-b-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("pages.admin.charges.details.platformLinks.headers.rel")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell
|
||||
class="bg-tertiary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
|
||||
>
|
||||
{$t("pages.admin.charges.details.platformLinks.headers.href")}
|
||||
</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody class="text-base">
|
||||
{#each platformLinks as item}
|
||||
<TableBodyRow class=" bg-white">
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-l-md border-t border-b border-l"
|
||||
>
|
||||
{item.type}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell class="border-variant1 border-t border-b">
|
||||
{item.rel}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-r-md border-t border-r border-b align-top"
|
||||
>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div
|
||||
class="w-full cursor-pointer leading-snug break-all whitespace-normal"
|
||||
style="word-break: break-word; text-decoration-line: underline;"
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
class=" text-secondary"
|
||||
target="_blank"
|
||||
>{item.href}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
text={$t("common.tooltip.coved")}
|
||||
tooltipClass="bg-secondary"
|
||||
className="size-5 cursor-copy shrink-0"
|
||||
>
|
||||
<button
|
||||
id={`copy-${item.href}`}
|
||||
type="button"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(item.href)}
|
||||
>
|
||||
<Copy />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="font-bold">{$t("pages.admin.charges.details.estimatedFee")}</p>
|
||||
<p>—</p>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="font-bold">{$t("pages.admin.charges.details.concept")}</p>
|
||||
<p class="truncate" title={concept}>{concept !== "" ? concept : "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold">{$t("contributions.grid.details.concept")}</p>
|
||||
<p>{concept !== "" ? concept : "—"}</p>
|
||||
</div>
|
||||
</Grid>
|
||||
<ActionsBtn />
|
||||
<ContentFooter {id} />
|
||||
{/if}
|
||||
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else}
|
||||
<ActionsBtn />
|
||||
{/if}
|
||||
|
||||
{#if footer}
|
||||
{@render footer()}
|
||||
{:else}
|
||||
<ContentFooter {id} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
apiGatewayChargesGetCollection,
|
||||
type ApiGatewayChargesGetCollectionData,
|
||||
} from "../../openapi/client";
|
||||
import Download from "../icons/Download.svelte";
|
||||
import Spinner from "../icons/Spinner.svelte";
|
||||
import Button from "../library/Button.svelte";
|
||||
|
||||
let { filters } = $props<{
|
||||
@@ -12,7 +14,7 @@
|
||||
|
||||
let isExporting = $state(false);
|
||||
|
||||
let currentTarget = filters?.target || "";
|
||||
let currentTarget = $derived(filters?.target || "");
|
||||
let selectedChargeStatus = "";
|
||||
let selectedRangeAmount = "";
|
||||
let selectedPaymentMethod = "";
|
||||
@@ -107,7 +109,7 @@
|
||||
}
|
||||
|
||||
if (allData.length === 0) {
|
||||
alert($t("contributions.export.noData") || "No se encontraron datos para exportar");
|
||||
alert($t("pages.admin.charges.export.noData") || "No se encontraron datos para exportar");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,8 +118,8 @@
|
||||
.map(([key, value]) => `${key}-${value}`)
|
||||
.join("_");
|
||||
|
||||
const baseFilename = $t("contributions.export.filename") || "gateway-charges";
|
||||
const itemsText = $t("contributions.export.itemsText") || "items";
|
||||
const baseFilename = $t("pages.admin.charges.export.filename") || "gateway-charges";
|
||||
const itemsText = $t("pages.admin.charges.export.itemsText") || "items";
|
||||
const filename = `${baseFilename}_${timestamp}${filterParts ? "_" + filterParts : ""}_${totalItems}${itemsText}.csv`;
|
||||
|
||||
const headers = Object.keys(allData[0]);
|
||||
@@ -157,7 +159,7 @@
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Error exporting CSV:", error);
|
||||
alert($t("contributions.export.error"));
|
||||
alert($t("pages.admin.charges.export.error"));
|
||||
} finally {
|
||||
isExporting = false;
|
||||
}
|
||||
@@ -169,40 +171,17 @@
|
||||
kind="secondary"
|
||||
onclick={handleExportCSV}
|
||||
disabled={isExporting}
|
||||
aria-label={$t("contributions.export.csv")}
|
||||
aria-label={$t("pages.admin.charges.export.csv")}
|
||||
>
|
||||
{#if isExporting}
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Spinner width="16px" height="16px" class="text-secondary" />
|
||||
<span class="text-secondary font-bold">
|
||||
{$t("contributions.export.exporting")}
|
||||
{$t("pages.admin.charges.export.exporting")}
|
||||
</span>
|
||||
{:else}
|
||||
<svg
|
||||
class="text-secondary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M2 14H14M5 8.6L8 11.6L11 8.6M8 2V10.4"
|
||||
stroke="#462949"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<Download width="16" height="16" class="text-secondary" />
|
||||
<span class="text-secondary font-bold">
|
||||
{$t("contributions.export.csv")}
|
||||
{$t("pages.admin.charges.export.csv")}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Search from "./Search.svelte";
|
||||
import Bullet from "../../components/icons/Bullet.svelte";
|
||||
import { t } from "../../i18n/store";
|
||||
import { type ApiGatewayChargesGetCollectionData } from "../../openapi/client/index";
|
||||
import FiltersIcon from "../icons/Filters.svelte";
|
||||
import Search from "../icons/Search.svelte";
|
||||
import Button from "../library/Button.svelte";
|
||||
import Grid from "../library/Grid.svelte";
|
||||
|
||||
let { filters, onApplyFilters, paymentMethodOptions, chargeStatusOptions, rangeAmountOptions } =
|
||||
@@ -26,7 +27,7 @@
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (dateFrom && dateTo && new Date(dateFrom) > new Date(dateTo)) {
|
||||
alert($t("contributions.filters.dateRange.errors.invalidRange"));
|
||||
alert($t("pages.admin.charges.filters.dateRange.errors.invalidRange"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,10 +67,11 @@
|
||||
<Search onSelectTarget={handleSelectTarget} />
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
kind="ghost"
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="border-secondary text-secondary relative inline-flex cursor-pointer items-center gap-2 rounded-3xl border px-6 py-4 font-bold text-nowrap"
|
||||
class="relative text-nowrap"
|
||||
>
|
||||
<span class="relative">
|
||||
<FiltersIcon />
|
||||
@@ -80,11 +82,11 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if showFilters}
|
||||
{$t("contributions.filters.btns.closeFilters")}
|
||||
{$t("pages.admin.charges.filters.btns.closeFilters")}
|
||||
{:else}
|
||||
{$t("contributions.filters.btns.openFilters")}
|
||||
{$t("pages.admin.charges.filters.btns.openFilters")}
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +98,7 @@
|
||||
bind:value={selectedPaymentMethod}
|
||||
>
|
||||
<option value="" disabled selected
|
||||
>{$t("contributions.filters.paymentMethod.title")}</option
|
||||
>{$t("pages.admin.charges.filters.paymentMethod.title")}</option
|
||||
>
|
||||
{#each paymentMethodOptions as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
@@ -108,7 +110,7 @@
|
||||
bind:value={selectedChargeStatus}
|
||||
>
|
||||
<option value="" disabled
|
||||
>{$t("contributions.filters.chargeStatus.title")}</option
|
||||
>{$t("pages.admin.charges.filters.chargeStatus.title")}</option
|
||||
>
|
||||
{#each chargeStatusOptions as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
@@ -120,7 +122,7 @@
|
||||
bind:value={selectedRangeAmount}
|
||||
>
|
||||
<option value="" disabled
|
||||
>{$t("contributions.filters.rangeAmount.title")}</option
|
||||
>{$t("pages.admin.charges.filters.rangeAmount.title")}</option
|
||||
>
|
||||
{#each rangeAmountOptions as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
@@ -129,7 +131,7 @@
|
||||
|
||||
<div class="relative">
|
||||
<label for="dateFrom" class="absolute top-0.5 left-4 text-xs text-gray-500">
|
||||
{$t("contributions.filters.dateRange.initDate")}
|
||||
{$t("pages.admin.charges.filters.dateRange.initDate")}
|
||||
</label>
|
||||
<input
|
||||
id="dateFrom"
|
||||
@@ -142,7 +144,7 @@
|
||||
|
||||
<div class="relative">
|
||||
<label for="dateTo" class="absolute top-0.5 left-4 text-xs text-gray-500">
|
||||
{$t("contributions.filters.dateRange.endDate")}
|
||||
{$t("pages.admin.charges.filters.dateRange.endDate")}
|
||||
</label>
|
||||
<input
|
||||
id="dateTo"
|
||||
@@ -155,12 +157,9 @@
|
||||
</Grid>
|
||||
|
||||
<div class="col-span-3 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-primary text-secondary cursor-pointer rounded-3xl px-6 py-4 text-base font-bold"
|
||||
>
|
||||
{$t("contributions.filters.btns.apply")}
|
||||
</button>
|
||||
<Button type="submit" kind="primary">
|
||||
{$t("pages.admin.charges.filters.btns.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -44,15 +44,15 @@
|
||||
}
|
||||
|
||||
if (tag.title === "checkout.gateway") {
|
||||
tag.value = $t(`contributions.filters.paymentMethod.options.${tag.value}`);
|
||||
tag.value = $t(`pages.admin.charges.filters.paymentMethod.options.${tag.value}`);
|
||||
}
|
||||
|
||||
if (tag.title === "status") {
|
||||
tag.value = $t(`contributions.filters.chargeStatus.options.${tag.value}`);
|
||||
tag.value = $t(`pages.admin.charges.filters.chargeStatus.options.${tag.value}`);
|
||||
}
|
||||
|
||||
if (tag.title === "money.amount[gte]")
|
||||
tag.value = $t(`contributions.filters.rangeAmount.options.${tag.value}`);
|
||||
tag.value = $t(`pages.admin.charges.filters.rangeAmount.options.${tag.value}`);
|
||||
});
|
||||
|
||||
return tags;
|
||||
|
||||
@@ -86,18 +86,18 @@
|
||||
{#if $totalItems > 0}
|
||||
<span class="text-content text-sm font-bold">
|
||||
{@html $t(
|
||||
"contributions.pagination.showing",
|
||||
"pages.admin.charges.pagination.showing",
|
||||
{ items: $itemsPerPage, total: $totalItems },
|
||||
{ allowHTML: true },
|
||||
)}
|
||||
</span>
|
||||
{:else if $totalItems === 0 && !$isLoading}
|
||||
<span class="text-content text-sm font-bold">
|
||||
{@html $t("contributions.pagination.noResults")}
|
||||
{@html $t("pages.admin.charges.pagination.noResults")}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-content text-sm font-bold">
|
||||
{@html $t("contributions.pagination.unloaded")}
|
||||
{@html $t("pages.admin.charges.pagination.unloaded")}
|
||||
</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
apiTipjarsGetCollection,
|
||||
apiUsersGetCollection,
|
||||
} from "../../openapi/client";
|
||||
import SearchIcon from "../../svgs/SearchIcon.svelte";
|
||||
import { highlightMatch } from "../../utils/highlights";
|
||||
import CloseIcon from "../icons/Close.svelte";
|
||||
import SearchIcon from "../icons/Search.svelte";
|
||||
|
||||
import type { ProjectJsonld, TipjarJsonld, UserJsonld } from "../../openapi/client/index";
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
member: T[];
|
||||
};
|
||||
|
||||
let { onSelectTarget } = $props();
|
||||
let { onSelectTarget } = $props<{ onSelectTarget: (accounting: string) => void }>();
|
||||
|
||||
let query = $state("");
|
||||
let results = $state<ResultItem[]>([]);
|
||||
@@ -52,7 +52,7 @@
|
||||
headers: { Accept: "application/ld+json" },
|
||||
}),
|
||||
apiUsersGetCollection({
|
||||
query: { query: trimmed },
|
||||
query: { handle: trimmed },
|
||||
headers: { Accept: "application/ld+json" },
|
||||
}),
|
||||
]);
|
||||
@@ -100,7 +100,7 @@
|
||||
bind:value={query}
|
||||
oninput={(e) =>
|
||||
handleInput(e.target instanceof HTMLInputElement ? e.target.value : "")}
|
||||
placeholder={$t("contributions.filters.search.placeholder")}
|
||||
placeholder={$t("pages.admin.charges.filters.search.placeholder")}
|
||||
class="border-secondary w-full rounded-3xl border p-4"
|
||||
minlength="4"
|
||||
/>
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="absolute top-full z-10 my-8 w-full space-y-4 rounded-lg bg-gray-200 p-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
{@html $t(
|
||||
"contributions.filters.search.resultsFound",
|
||||
"pages.admin.charges.filters.search.resultsFound",
|
||||
{
|
||||
totalItems: totalItems,
|
||||
query: `<span class="font-bold">${query}</span>`,
|
||||
@@ -142,7 +142,7 @@
|
||||
{#if results.some((r) => r.type === "project")}
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-bold text-gray-700 uppercase">
|
||||
{$t("contributions.filters.search.labels.projects")}
|
||||
{$t("domain.charges.entityLabels.projects")}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each results.filter((r) => r.type === "project") as item}
|
||||
@@ -150,7 +150,7 @@
|
||||
type="button"
|
||||
class="w-full cursor-pointer rounded-lg border bg-white p-4 text-left shadow transition hover:shadow-md"
|
||||
onclick={() => {
|
||||
onSelectTarget(item.data.accounting);
|
||||
onSelectTarget(item.data.accounting ?? "");
|
||||
query = "";
|
||||
results = [];
|
||||
totalItems = 0;
|
||||
@@ -178,7 +178,7 @@
|
||||
{#if results.some((r) => r.type === "tipjar")}
|
||||
<div>
|
||||
<h3 class="mt-6 mb-2 text-sm font-bold text-gray-700 uppercase">
|
||||
{$t("contributions.filters.search.labels.tipjars")}
|
||||
{$t("domain.charges.entityLabels.tipjars")}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each results.filter((r) => r.type === "tipjar") as item}
|
||||
@@ -186,7 +186,7 @@
|
||||
type="button"
|
||||
class="w-full cursor-pointer rounded-lg border bg-white p-4 text-left shadow transition hover:shadow-md"
|
||||
onclick={() => {
|
||||
onSelectTarget(item.data.accounting);
|
||||
onSelectTarget(item.data.accounting ?? "");
|
||||
query = "";
|
||||
results = [];
|
||||
totalItems = 0;
|
||||
@@ -200,7 +200,7 @@
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500 italic">
|
||||
{$t("contributions.filters.search.labels.tipjar-id")}: {item
|
||||
{$t("domain.charges.entityLabels.tipjar-id")}: {item
|
||||
.data.id}
|
||||
</div>
|
||||
</button>
|
||||
@@ -212,7 +212,7 @@
|
||||
{#if results.some((r) => r.type === "user")}
|
||||
<div>
|
||||
<h3 class="mt-6 mb-2 text-sm font-bold text-gray-700 uppercase">
|
||||
{$t("contributions.filters.search.labels.users")}
|
||||
{$t("domain.charges.entityLabels.users")}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each results.filter((r) => r.type === "user") as item}
|
||||
@@ -220,7 +220,7 @@
|
||||
type="button"
|
||||
class="w-full cursor-pointer rounded-lg border bg-white p-4 text-left shadow transition hover:shadow-md"
|
||||
onclick={() => {
|
||||
onSelectTarget(item.data.accounting);
|
||||
onSelectTarget(item.data.accounting ?? "");
|
||||
query = "";
|
||||
results = [];
|
||||
totalItems = 0;
|
||||
@@ -242,7 +242,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">{$t("contributions.filters.search.noResults")}</p>
|
||||
<p class="text-sm text-gray-400">{$t("pages.admin.charges.filters.search.noResults")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,15 +3,36 @@
|
||||
|
||||
import "flickity/css/flickity.css";
|
||||
import TotalizerCard from "./TotalizerCard.svelte";
|
||||
import { session } from "../../auth/store";
|
||||
import { t } from "../../i18n/store";
|
||||
import { apiAccountingsIdGet, apiTipjarsIdGet } from "../../openapi/client/sdk.gen";
|
||||
import { totalItems, isLoading } from "../../stores/chargesPaginationAndSort";
|
||||
import { formatCurrency } from "../../utils/currencies";
|
||||
import { extractId } from "../../utils/extractId";
|
||||
import { isEnabled, tipjarId } from "../../utils/tipping";
|
||||
|
||||
import type { Options } from "flickity";
|
||||
import type Flickity from "flickity";
|
||||
|
||||
let mainCarousel: HTMLDivElement;
|
||||
let flickity: Flickity;
|
||||
let isSliderLoaded = $state(false);
|
||||
let totalTips = $state<string>("—");
|
||||
|
||||
async function loadTotalTips() {
|
||||
if (!isEnabled || !$session) return;
|
||||
const headers = $session.token.asHttpHeaders;
|
||||
|
||||
const { data: tipjar } = await apiTipjarsIdGet({ path: { id: tipjarId }, headers });
|
||||
const accountingId = tipjar?.accounting ? extractId(tipjar.accounting) : null;
|
||||
if (!accountingId) return;
|
||||
|
||||
const { data: accounting } = await apiAccountingsIdGet({
|
||||
path: { id: accountingId },
|
||||
headers,
|
||||
});
|
||||
if (accounting?.balance) {
|
||||
totalTips = formatCurrency(accounting.balance.amount, accounting.balance.currency);
|
||||
}
|
||||
}
|
||||
|
||||
let options: Options = {
|
||||
cellAlign: "left",
|
||||
@@ -31,25 +52,22 @@
|
||||
let slides: { title: string; amount: string | number }[] = $state([]);
|
||||
|
||||
const loadSlides = () => {
|
||||
let slidesArr = [];
|
||||
|
||||
// Example dynamic data, just for testing. Pending real data integration.
|
||||
slidesArr.push({
|
||||
title: $t("admin.projects.totalizers.selected"),
|
||||
amount: $totalItems,
|
||||
});
|
||||
slidesArr.push({ title: $t("admin.charges.totalizers.totalCharges"), amount: "250,98€" });
|
||||
slidesArr.push({ title: $t("admin.charges.totalizers.totalTips"), amount: "250,96€" });
|
||||
slidesArr.push({ title: $t("admin.charges.totalizers.totalFees"), amount: "250,97€" });
|
||||
|
||||
return slidesArr;
|
||||
return [
|
||||
{
|
||||
title: $t("admin.projects.totalizers.selected"),
|
||||
amount: $totalItems,
|
||||
},
|
||||
{ title: $t("admin.charges.totalizers.totalCharges"), amount: "—" },
|
||||
{ title: $t("admin.charges.totalizers.totalTips"), amount: totalTips },
|
||||
{ title: $t("admin.charges.totalizers.totalFees"), amount: "—" },
|
||||
];
|
||||
};
|
||||
|
||||
const loadFlickity = async (elem: HTMLElement) => {
|
||||
try {
|
||||
const FlickityModule = await import("flickity");
|
||||
const FlickityClass = FlickityModule.default;
|
||||
flickity = new FlickityClass(elem, options);
|
||||
new FlickityClass(elem, options);
|
||||
isSliderLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("Flickity failed to load:", err);
|
||||
@@ -63,6 +81,7 @@
|
||||
onMount(() => {
|
||||
loadSlides();
|
||||
if (mainCarousel) loadFlickity(mainCarousel);
|
||||
loadTotalTips();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -85,12 +104,12 @@
|
||||
|
||||
<style>
|
||||
:global(.flickity-button-icon) {
|
||||
fill: #462949;
|
||||
fill: var(--color-secondary);
|
||||
}
|
||||
|
||||
:global(.flickity-prev-next-button.previous),
|
||||
:global(.flickity-prev-next-button.next) {
|
||||
background: #e6e5f7;
|
||||
background: var(--color-variant1);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
import { isLoading, itemsPerPage, sortOptions } from "../../stores/chargesPaginationAndSort.ts";
|
||||
import Loader from "../../svgs/Loader.svelte";
|
||||
import { formatCurrency } from "../../utils/currencies";
|
||||
import Chevron from "../icons/Chevron.svelte";
|
||||
import Tag from "../library/Tag.svelte";
|
||||
|
||||
import type {
|
||||
Accounting,
|
||||
@@ -43,13 +45,13 @@
|
||||
};
|
||||
|
||||
const tableHeaders = [
|
||||
{ name: "contributions.table.headers.target", sortable: false },
|
||||
{ name: "contributions.table.headers.amount", sortable: true, sortKey: "amount" },
|
||||
{ name: "contributions.table.headers.origin", sortable: false },
|
||||
{ name: "contributions.table.headers.paymentMethod", sortable: false },
|
||||
{ name: "contributions.table.headers.date", sortable: true, sortKey: "date" },
|
||||
{ name: "contributions.table.headers.chargeStatus", sortable: false, sortKey: "status" },
|
||||
{ name: "contributions.table.headers.refundToWallet", sortable: false },
|
||||
{ name: "pages.admin.charges.headers.target", sortable: false },
|
||||
{ name: "pages.admin.charges.headers.amount", sortable: true, sortKey: "amount" },
|
||||
{ name: "pages.admin.charges.headers.origin", sortable: false },
|
||||
{ name: "pages.admin.charges.headers.paymentMethod", sortable: false },
|
||||
{ name: "pages.admin.charges.headers.date", sortable: true, sortKey: "date" },
|
||||
{ name: "pages.admin.charges.headers.chargeStatus", sortable: false, sortKey: "status" },
|
||||
{ name: "pages.admin.charges.headers.refundToWallet", sortable: false },
|
||||
{ name: "", sortable: false }, // For the empty expand/collapse button at the end of the table
|
||||
];
|
||||
|
||||
@@ -219,7 +221,7 @@
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<p class="text-content font-bold">
|
||||
{$t("contributions.filters.order.title")}
|
||||
{$t("pages.admin.charges.filters.order.title")}
|
||||
</p>
|
||||
<select
|
||||
value={selectedSort}
|
||||
@@ -235,7 +237,7 @@
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<p class="text-content font-bold">
|
||||
{$t("contributions.filters.itemsPerPage.title")}
|
||||
{$t("pages.admin.charges.filters.itemsPerPage.title")}
|
||||
</p>
|
||||
<select
|
||||
name="itemsPerPage"
|
||||
@@ -244,7 +246,7 @@
|
||||
bind:value={$itemsPerPage}
|
||||
disabled={$isLoading}
|
||||
>
|
||||
{#each Object.entries($t("contributions.filters.itemsPerPage.options")) as [value, label]}
|
||||
{#each Object.entries($t("pages.admin.charges.filters.itemsPerPage.options")) as [value, label]}
|
||||
<option value={Number(value)}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -261,7 +263,7 @@
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between {header.name ===
|
||||
'contributions.table.headers.chargeStatus'
|
||||
'pages.admin.charges.headers.chargeStatus'
|
||||
? 'justify-center'
|
||||
: ''}"
|
||||
>
|
||||
@@ -288,7 +290,7 @@
|
||||
{:else if charges.length === 0 && !$isLoading}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell colspan={tableHeaders.length} class="text-center">
|
||||
{$t("contributions.table.rows.noData")}
|
||||
{$t("pages.admin.charges.noData")}
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{:else}
|
||||
@@ -312,7 +314,7 @@
|
||||
>{charge.originDisplayName}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell class="border-variant1 border-t border-b p-4">
|
||||
{$t(`contributions.table.rows.payments.${charge.paymentMethod}`)}
|
||||
{$t(`domain.charges.payments.${charge.paymentMethod}`)}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell class="border-variant1 border-t border-b">
|
||||
{getDate(charge.dateCreated).date}
|
||||
@@ -325,11 +327,9 @@
|
||||
</TableBodyCell>
|
||||
<TableBodyCell class="border-variant1 border-t border-b p-4">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="flex items-center gap-1 rounded border border-black px-3 py-1 text-base font-medium text-black"
|
||||
>
|
||||
{$t(`contributions.table.rows.status.${charge.status}`)}
|
||||
</button>
|
||||
<Tag>
|
||||
{$t(`domain.charges.status.${charge.status}`)}
|
||||
</Tag>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
|
||||
@@ -338,41 +338,33 @@
|
||||
>
|
||||
<TableBodyCell
|
||||
class="border-variant1 rounded-r-md border-t border-r border-b p-4"
|
||||
><svg
|
||||
class={openRow === i
|
||||
? "rotate-180 transform transition-transform"
|
||||
: "transition-transform"}
|
||||
><Chevron
|
||||
direction={openRow === i ? "up" : "down"}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.5 8.25L12 15.75L19.5 8.25"
|
||||
stroke="#3D3D3D"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg></TableBodyCell
|
||||
class="text-black transition-transform"
|
||||
/></TableBodyCell
|
||||
>
|
||||
</TableBodyRow>
|
||||
{#if openRow === i}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell
|
||||
colspan={tableHeaders.length}
|
||||
class="border-variant1 bg-purple-soft rounded-lg border py-10 shadow-[0px_1px_3px_0px_#0000001A]"
|
||||
class="border-variant1 bg-purple-soft rounded-lg border p-0 shadow-[0px_1px_3px_0px_#0000001A]"
|
||||
>
|
||||
<DetailsRow
|
||||
platformLinks={charge.platformLinks}
|
||||
trackingCodes={charge.trackingCodes}
|
||||
dataTimeCreated={getDate(charge.dateCreated)}
|
||||
dataTimeUpdated={getDate(charge.dateUpdated)}
|
||||
id={charge.id ? String(charge.id) : "-"}
|
||||
refundToWallet={charge.refundToWallet}
|
||||
concept={charge.concept}
|
||||
/>
|
||||
<div class="detail-expand">
|
||||
<div class="detail-expand-content">
|
||||
<DetailsRow
|
||||
platformLinks={charge.platformLinks}
|
||||
trackingCodes={charge.trackingCodes}
|
||||
dataTimeCreated={getDate(charge.dateCreated)}
|
||||
dataTimeUpdated={getDate(charge.dateUpdated)}
|
||||
id={charge.id ? String(charge.id) : "-"}
|
||||
refundToWallet={charge.refundToWallet}
|
||||
concept={charge.concept}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/if}
|
||||
@@ -394,3 +386,37 @@
|
||||
|
||||
<Pagination />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-expand {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
animation: detail-expand 220ms ease-out both;
|
||||
}
|
||||
|
||||
.detail-expand-content {
|
||||
min-height: 0;
|
||||
animation: detail-content-in 220ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes detail-expand {
|
||||
from {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
to {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes detail-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</Header>
|
||||
<main
|
||||
class={twMerge(
|
||||
" mt-(--sticky-top) flex w-full max-w-screen flex-1 flex-col lg:max-h-none",
|
||||
"mt-(--sticky-top) flex w-full max-w-screen flex-1 flex-col lg:max-h-none",
|
||||
classes,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { twMerge, type ClassNameValue } from "tailwind-merge";
|
||||
|
||||
let { class: className = "", children } = $props();
|
||||
import type { HTMLStyleAttributes } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
class?: ClassNameValue;
|
||||
children: any;
|
||||
style?: HTMLStyleAttributes["style"];
|
||||
}
|
||||
let { class: classes = "", children, style }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
"bg-light-surface border-light-muted min-w-sm rounded-4xl border p-6 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)]",
|
||||
className,
|
||||
classes,
|
||||
)}
|
||||
{style}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
+134
-78
@@ -12,9 +12,14 @@
|
||||
itemsPerGroup = 1,
|
||||
gap = 24,
|
||||
showDots = true,
|
||||
class: className = "",
|
||||
class: classes = "",
|
||||
mobileItemsToShow = 1,
|
||||
desktopItemsToShow = 3,
|
||||
dotsPerItem = false,
|
||||
lockItemWidth = true,
|
||||
disableDrag = false,
|
||||
navButtonTop = "50%",
|
||||
centerNavButtons = true,
|
||||
children = null,
|
||||
onSelect = null,
|
||||
activeCard = $bindable(0),
|
||||
@@ -26,17 +31,27 @@
|
||||
class?: ClassNameValue;
|
||||
mobileItemsToShow?: number;
|
||||
desktopItemsToShow?: number;
|
||||
dotsPerItem?: boolean;
|
||||
lockItemWidth?: boolean;
|
||||
disableDrag?: boolean;
|
||||
navButtonTop?: string;
|
||||
centerNavButtons?: boolean;
|
||||
children?: any;
|
||||
onSelect?: ((card: ProjectUpdate) => void) | null;
|
||||
activeCard?: number;
|
||||
active?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const wrapperClasses = twMerge("relative w-full", className);
|
||||
const wrapperClasses = twMerge("relative w-full", classes);
|
||||
const navButtonClasses: ClassNameValue = twMerge(
|
||||
"bg-variant1 absolute z-10 hidden h-10 w-10 rounded-full p-2 shadow-md disabled:opacity-50 lg:block",
|
||||
centerNavButtons ? "-translate-y-1/2" : "",
|
||||
);
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
let totalGroups = $state(0);
|
||||
let totalItems = $state(0);
|
||||
let activeGroup = $state(0);
|
||||
let isAtStart = $state(true);
|
||||
let isAtEnd = $state(false);
|
||||
@@ -48,6 +63,23 @@
|
||||
|
||||
const observerMap = new Map<number, HTMLElement>();
|
||||
|
||||
function getActualChildren() {
|
||||
if (!container) return [];
|
||||
|
||||
let actualChildren: HTMLElement[] = [];
|
||||
const directChildren = Array.from(container.children) as HTMLElement[];
|
||||
|
||||
for (const child of directChildren) {
|
||||
if (child.tagName.toLowerCase() === "astro-slot") {
|
||||
actualChildren.push(...(Array.from(child.children) as HTMLElement[]));
|
||||
} else {
|
||||
actualChildren.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return actualChildren;
|
||||
}
|
||||
|
||||
function getCurrentItemsToShow(): number {
|
||||
if (!browser) return desktopItemsToShow;
|
||||
return window.innerWidth >= 1024 ? desktopItemsToShow : mobileItemsToShow;
|
||||
@@ -57,44 +89,48 @@
|
||||
if (!browser || !container || !mounted) return;
|
||||
|
||||
try {
|
||||
const containerWidth = container.offsetWidth;
|
||||
const actualChildren = getActualChildren();
|
||||
|
||||
if (containerWidth === 0) {
|
||||
setTimeout(updateItemWidths, 50);
|
||||
return;
|
||||
if (lockItemWidth) {
|
||||
const containerWidth = container.offsetWidth;
|
||||
|
||||
if (containerWidth === 0) {
|
||||
setTimeout(updateItemWidths, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(container);
|
||||
const paddingLeft = parseFloat(styles.paddingLeft);
|
||||
const paddingRight = parseFloat(styles.paddingRight);
|
||||
const available = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
const visibleItems = getCurrentItemsToShow();
|
||||
const childWidth = (available - gap * (visibleItems - 1)) / visibleItems;
|
||||
|
||||
actualChildren.forEach((child) => {
|
||||
child.style.minWidth = `${childWidth}px`;
|
||||
child.style.maxWidth = `${childWidth}px`;
|
||||
child.style.width = `${childWidth}px`;
|
||||
child.style.flex = "0 0 auto";
|
||||
child.style.overflow = "hidden";
|
||||
});
|
||||
} else {
|
||||
actualChildren.forEach((child) => {
|
||||
child.style.removeProperty("min-width");
|
||||
child.style.removeProperty("max-width");
|
||||
child.style.removeProperty("width");
|
||||
child.style.flex = "0 0 auto";
|
||||
child.style.overflow = "hidden";
|
||||
});
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(container);
|
||||
const paddingLeft = parseFloat(styles.paddingLeft);
|
||||
const paddingRight = parseFloat(styles.paddingRight);
|
||||
const available = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
const visibleItems = getCurrentItemsToShow();
|
||||
const childWidth = (available - gap * (visibleItems - 1)) / visibleItems;
|
||||
|
||||
const directChildren = Array.from(container.children) as HTMLElement[];
|
||||
|
||||
for (const el of directChildren) {
|
||||
for (const el of Array.from(container.children) as HTMLElement[]) {
|
||||
if (el.tagName.toLowerCase() === "astro-slot") {
|
||||
const slotChildren = Array.from(el.children) as HTMLElement[];
|
||||
slotChildren.forEach((child) => {
|
||||
child.style.minWidth = `${childWidth}px`;
|
||||
child.style.maxWidth = `${childWidth}px`;
|
||||
child.style.width = `${childWidth}px`;
|
||||
child.style.flex = "0 0 auto";
|
||||
child.style.overflow = "hidden";
|
||||
});
|
||||
el.style.minWidth = "";
|
||||
el.style.maxWidth = "";
|
||||
el.style.width = "";
|
||||
el.style.flex = "";
|
||||
el.style.display = "contents";
|
||||
} else {
|
||||
el.style.minWidth = `${childWidth}px`;
|
||||
el.style.maxWidth = `${childWidth}px`;
|
||||
el.style.width = `${childWidth}px`;
|
||||
el.style.flex = "0 0 auto";
|
||||
el.style.overflow = "hidden";
|
||||
}
|
||||
}
|
||||
updateNavForShort();
|
||||
@@ -107,16 +143,7 @@
|
||||
if (!browser || !container || !intersectionObs || !mounted) return;
|
||||
|
||||
try {
|
||||
let actualChildren: HTMLElement[] = [];
|
||||
const directChildren = Array.from(container.children) as HTMLElement[];
|
||||
|
||||
for (const child of directChildren) {
|
||||
if (child.tagName.toLowerCase() === "astro-slot") {
|
||||
actualChildren.push(...(Array.from(child.children) as HTMLElement[]));
|
||||
} else {
|
||||
actualChildren.push(child);
|
||||
}
|
||||
}
|
||||
const actualChildren = getActualChildren();
|
||||
|
||||
intersectionObs.disconnect();
|
||||
observerMap.clear();
|
||||
@@ -127,16 +154,30 @@
|
||||
});
|
||||
|
||||
totalGroups = Math.ceil(actualChildren.length / itemsPerGroup);
|
||||
totalItems = actualChildren.length;
|
||||
updateNavForShort();
|
||||
} catch (error) {
|
||||
console.warn("Carousel: Error observing visibility:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateNavState(group: number) {
|
||||
activeCard = group;
|
||||
isAtStart = group === 0;
|
||||
isAtEnd = group === totalGroups - 1;
|
||||
function getPositionCount() {
|
||||
return dotsPerItem ? totalItems : totalGroups;
|
||||
}
|
||||
|
||||
function getPositionFromItemIndex(index: number) {
|
||||
return dotsPerItem ? index : Math.floor(index / itemsPerGroup);
|
||||
}
|
||||
|
||||
function getTargetItemIndex(position: number) {
|
||||
return dotsPerItem ? position : position * itemsPerGroup;
|
||||
}
|
||||
|
||||
function updateNavState(position: number) {
|
||||
activeCard = position;
|
||||
activeGroup = position;
|
||||
isAtStart = position === 0;
|
||||
isAtEnd = position === getPositionCount() - 1;
|
||||
}
|
||||
|
||||
function updateNavForShort() {
|
||||
@@ -144,51 +185,58 @@
|
||||
|
||||
try {
|
||||
isScrollable = container.scrollWidth > container.clientWidth;
|
||||
isAtStart = true;
|
||||
isAtEnd = !isScrollable;
|
||||
isAtStart = activeCard === 0;
|
||||
isAtEnd = !isScrollable || activeCard === getPositionCount() - 1;
|
||||
} catch (error) {
|
||||
console.warn("Carousel: Error updating navigation state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToGroup(i: number) {
|
||||
async function scrollToGroup(i: number) {
|
||||
if (!browser || !container || !mounted) return;
|
||||
|
||||
try {
|
||||
let actualChildren: HTMLElement[] = [];
|
||||
const directChildren = Array.from(container.children) as HTMLElement[];
|
||||
|
||||
for (const child of directChildren) {
|
||||
if (child.tagName.toLowerCase() === "astro-slot") {
|
||||
actualChildren.push(...(Array.from(child.children) as HTMLElement[]));
|
||||
} else {
|
||||
actualChildren.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
programmaticScroll = true;
|
||||
updateNavState(i);
|
||||
const target = actualChildren[i * itemsPerGroup];
|
||||
target?.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
|
||||
setTimeout(() => {
|
||||
await tick();
|
||||
updateItemWidths();
|
||||
const targetItemIndex = getTargetItemIndex(i);
|
||||
const actualChildren = getActualChildren();
|
||||
const target = actualChildren[targetItemIndex];
|
||||
scrollToTarget(target, "smooth");
|
||||
|
||||
if (programmaticScrollTimeout) clearTimeout(programmaticScrollTimeout);
|
||||
programmaticScrollTimeout = setTimeout(() => {
|
||||
const settledTarget = getActualChildren()[targetItemIndex];
|
||||
scrollToTarget(settledTarget, "auto");
|
||||
updateNavForShort();
|
||||
programmaticScroll = false;
|
||||
}, 600);
|
||||
}, 360);
|
||||
} catch (error) {
|
||||
console.warn("Carousel: Error scrolling to group:", error);
|
||||
programmaticScroll = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToTarget(target: HTMLElement | undefined, behavior: ScrollBehavior) {
|
||||
if (!container || !target) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const left = targetRect.left - containerRect.left + container.scrollLeft;
|
||||
|
||||
container.scrollTo({ left, behavior });
|
||||
}
|
||||
|
||||
function scroll(dir: "left" | "right") {
|
||||
const lastPosition = getPositionCount() - 1;
|
||||
const next =
|
||||
dir === "right"
|
||||
? Math.min(activeCard + 1, totalGroups - 1)
|
||||
: Math.max(activeCard - 1, 0);
|
||||
dir === "right" ? Math.min(activeCard + 1, lastPosition) : Math.max(activeCard - 1, 0);
|
||||
scrollToGroup(next);
|
||||
}
|
||||
|
||||
function handleStart(x: number) {
|
||||
if (!browser || !container || !mounted) return;
|
||||
if (disableDrag || !browser || !container || !mounted) return;
|
||||
|
||||
isDragging = true;
|
||||
startX = x - container.offsetLeft;
|
||||
@@ -196,7 +244,7 @@
|
||||
}
|
||||
|
||||
function handleMove(x: number, ev: Event) {
|
||||
if (!browser || !container || !mounted || !isDragging) return;
|
||||
if (disableDrag || !browser || !container || !mounted || !isDragging) return;
|
||||
|
||||
ev.preventDefault();
|
||||
const walk = (x - container.offsetLeft - startX) * 1.5;
|
||||
@@ -204,6 +252,8 @@
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (disableDrag) return;
|
||||
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
@@ -212,6 +262,7 @@
|
||||
let mutationObs: MutationObserver | undefined;
|
||||
let mounted = false;
|
||||
let programmaticScroll = false;
|
||||
let programmaticScrollTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
// Ensure we're in the browser and DOM is ready
|
||||
@@ -235,7 +286,7 @@
|
||||
if (element === e.target) {
|
||||
minGroup = Math.min(
|
||||
minGroup,
|
||||
Math.floor(index / itemsPerGroup),
|
||||
getPositionFromItemIndex(index),
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -264,7 +315,7 @@
|
||||
observeVisibility();
|
||||
}
|
||||
});
|
||||
mutationObs.observe(container, { childList: true });
|
||||
mutationObs.observe(container, { childList: true, subtree: true });
|
||||
|
||||
window.addEventListener("resize", updateItemWidths);
|
||||
} catch (error) {
|
||||
@@ -279,6 +330,7 @@
|
||||
if (intersectionObs) intersectionObs.disconnect();
|
||||
if (resizeObs) resizeObs.disconnect();
|
||||
if (mutationObs) mutationObs.disconnect();
|
||||
if (programmaticScrollTimeout) clearTimeout(programmaticScrollTimeout);
|
||||
if (browser) {
|
||||
window.removeEventListener("resize", updateItemWidths);
|
||||
}
|
||||
@@ -289,7 +341,8 @@
|
||||
<div class={wrapperClasses}>
|
||||
<button
|
||||
onclick={() => scroll("left")}
|
||||
class="bg-variant1 absolute top-1/2 -left-4 z-10 hidden h-10 w-10 -translate-y-1/2 rounded-full p-2 shadow-md disabled:opacity-50 lg:block"
|
||||
class={twMerge(navButtonClasses, "-left-4")}
|
||||
style:top={navButtonTop}
|
||||
disabled={isAtStart}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
@@ -301,10 +354,12 @@
|
||||
bind:this={container}
|
||||
role="region"
|
||||
aria-label="Carousel"
|
||||
class="hide-scrollbar flex w-full overflow-x-auto scroll-smooth select-none"
|
||||
class:cursor-grab={isScrollable && !isDragging}
|
||||
class:cursor-default={!isScrollable}
|
||||
class:cursor-grabbing={isDragging && isScrollable}
|
||||
class="hide-scrollbar flex w-full scroll-smooth select-none"
|
||||
class:overflow-x-auto={!disableDrag}
|
||||
class:overflow-x-hidden={disableDrag}
|
||||
class:cursor-grab={isScrollable && !isDragging && !disableDrag}
|
||||
class:cursor-default={!isScrollable || disableDrag}
|
||||
class:cursor-grabbing={isDragging && isScrollable && !disableDrag}
|
||||
style="gap: {gap}px"
|
||||
onmousedown={(e) => handleStart(e.pageX)}
|
||||
onmousemove={(e) => handleMove(e.pageX, e)}
|
||||
@@ -322,16 +377,17 @@
|
||||
|
||||
<button
|
||||
onclick={() => scroll("right")}
|
||||
class="bg-variant1 absolute top-1/2 -right-4 z-10 hidden h-10 w-10 -translate-y-1/2 rounded-full p-2 shadow-md disabled:opacity-50 lg:block"
|
||||
class={twMerge(navButtonClasses, "-right-4")}
|
||||
style:top={navButtonTop}
|
||||
disabled={isAtEnd}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ArrowSliderIcon />
|
||||
</button>
|
||||
|
||||
{#if showDots && totalGroups > 1}
|
||||
{#if showDots && getPositionCount() > 1}
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
{#each Array(totalGroups) as _, i}
|
||||
{#each Array(getPositionCount()) as _, i}
|
||||
<button
|
||||
onclick={() => scrollToGroup(i)}
|
||||
class="h-2 w-2 rounded-full transition-all"
|
||||
|
||||
@@ -18,6 +18,18 @@
|
||||
let isDragging = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const mimeExtensions: Record<string, string> = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
};
|
||||
|
||||
let supportedTypes = $derived(
|
||||
accept.map((mime: string) => mimeExtensions[mime] ?? `.${mime.split("/")[1]}`).join(", "),
|
||||
);
|
||||
|
||||
function validate(file: File) {
|
||||
const validSize = file.size <= maxSizeMB * 1024 * 1024;
|
||||
const validType = accept.includes(file.type);
|
||||
@@ -96,7 +108,7 @@
|
||||
<div class="text-content flex justify-between text-sm/4 font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<WarningIcon width="16" height="16" />
|
||||
<span>{$t("system.constraint.file.supportedTypes")}</span>
|
||||
<span>{$t("system.constraint.file.supportedTypes", { types: supportedTypes })}</span>
|
||||
</div>
|
||||
<span>{$t("system.constraint.file.maxAllowedSize", { maxSizeMB })}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ import Grid from "../library/Grid.svelte";
|
||||
const { t } = Astro.locals;
|
||||
---
|
||||
|
||||
<section class="wrapper hero-section flex min-h-[60vh] items-center">
|
||||
<div class="container mx-auto">
|
||||
<section class="wrapper hero-section flex">
|
||||
<div class="container mx-auto py-16 lg:py-24">
|
||||
<Grid class="grid-cols-1 items-center gap-12 lg:grid-cols-2" client:load>
|
||||
<!-- Hero Content -->
|
||||
<div class="space-y-8">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import iso3166 from "iso-3166-2";
|
||||
import { type ClassNameValue } from "tailwind-merge";
|
||||
|
||||
import MapIcon from "./icons/Location.svelte";
|
||||
import { locale } from "../i18n/store";
|
||||
@@ -12,9 +13,11 @@
|
||||
let {
|
||||
territory,
|
||||
lang: initialLang = undefined,
|
||||
class: classes = "",
|
||||
}: {
|
||||
territory: Territory;
|
||||
lang?: Locale;
|
||||
class?: ClassNameValue;
|
||||
} = $props();
|
||||
|
||||
const lang = initialLang || $locale;
|
||||
@@ -40,7 +43,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tag>
|
||||
<Tag class={classes}>
|
||||
<MapIcon />
|
||||
<span>{displayName(territory)}</span>
|
||||
</Tag>
|
||||
|
||||
@@ -8,11 +8,13 @@ Converted from CampaignCard.astro to maintain exact functionality
|
||||
|
||||
import Clock from "../../components/icons/Clock.svelte";
|
||||
import { t } from "../../i18n/store";
|
||||
import { client } from "../../openapi/client/client.gen";
|
||||
import { formatCurrency } from "../../utils/currencies";
|
||||
import Flames from "../icons/Flames.svelte";
|
||||
import CampaignStatusBadge from "../library/CampaignStatusBadge.svelte";
|
||||
import Tag from "../library/Tag.svelte";
|
||||
|
||||
import type { Accounting, Money } from "../../openapi/client";
|
||||
import type { Campaign, CampaignSize } from "../../types/campaign";
|
||||
|
||||
interface Props {
|
||||
@@ -31,6 +33,27 @@ Converted from CampaignCard.astro to maintain exact functionality
|
||||
class: className = "",
|
||||
}: Props = $props();
|
||||
|
||||
// Balance pre-loaded from server (home page); fetched client-side via Project.accounting IRI when not provided
|
||||
let obtained = $state<Money | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (obtained === undefined) {
|
||||
if (campaign.obtained) {
|
||||
obtained = campaign.obtained;
|
||||
} else if (campaign.accounting) {
|
||||
(
|
||||
client.get({ url: campaign.accounting }) as unknown as Promise<{
|
||||
data: Accounting;
|
||||
}>
|
||||
)
|
||||
.then(({ data }) => {
|
||||
if (data?.balance) obtained = data.balance as Money;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Define responsive classes based on size
|
||||
// Large cards span 2 columns in lg+ (3-column grid), 2 columns in md (2-column grid), full width on mobile
|
||||
const sizeClasses = $derived(
|
||||
@@ -40,7 +63,7 @@ Converted from CampaignCard.astro to maintain exact functionality
|
||||
const imageHeight = "h-53.75"; // More rectangular proportions matching design
|
||||
|
||||
// Calculate funding status and remaining amount
|
||||
const hasReachedMinimum = $derived(campaign.obtained.amount >= campaign.minimum.amount);
|
||||
const hasReachedMinimum = $derived((obtained?.amount ?? 0) >= (campaign.minimum.amount ?? 0));
|
||||
|
||||
// Determine status badge text based on funding level
|
||||
// Using lookup pattern for consistency with other i18n implementations
|
||||
@@ -49,27 +72,6 @@ Converted from CampaignCard.astro to maintain exact functionality
|
||||
return $t(`home.campaigns.status.${key}`);
|
||||
});
|
||||
|
||||
// Calculate remaining amount (to minimum or optimum)
|
||||
const remainingToGoal = $derived(() => {
|
||||
if (hasReachedMinimum && campaign.optimum) {
|
||||
// Show remaining to optimum if minimum is reached
|
||||
const remaining = campaign.optimum.amount - campaign.obtained.amount;
|
||||
return {
|
||||
amount: remaining > 0 ? remaining : 0,
|
||||
label: $t("pages.home.campaigns.remaining.toOptimum"),
|
||||
currency: campaign.optimum.currency,
|
||||
};
|
||||
} else {
|
||||
// Show remaining to minimum
|
||||
const remaining = campaign.minimum.amount - campaign.obtained.amount;
|
||||
return {
|
||||
amount: remaining > 0 ? remaining : 0,
|
||||
label: $t("pages.home.campaigns.remaining.toMinimum"),
|
||||
currency: campaign.minimum.currency,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Get first category only (as per review comments)
|
||||
const firstCategory = $derived(() => {
|
||||
if (Array.isArray(campaign.category)) {
|
||||
@@ -164,15 +166,16 @@ Converted from CampaignCard.astro to maintain exact functionality
|
||||
>{$t("pages.home.campaigns.obtained")}</span
|
||||
>
|
||||
<span class="text-secondary text-2xl font-bold">
|
||||
{formatCurrency(
|
||||
campaign.obtained.amount,
|
||||
campaign.obtained.currency,
|
||||
)}
|
||||
{#if obtained}
|
||||
{formatCurrency(obtained.amount, obtained.currency)}
|
||||
{:else}
|
||||
<span class="text-content text-sm">{$t("system.loading")}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Remaining to Goal -->
|
||||
<div class="flex flex-col gap-1 text-right">
|
||||
{#if campaign.optimum && campaign.obtained.amount >= campaign.minimum.amount}
|
||||
{#if campaign.optimum && (obtained?.amount ?? 0) >= (campaign.minimum.amount ?? 0)}
|
||||
<span class="text-secondary text-base">
|
||||
{$t("pages.home.campaigns.optimum")}
|
||||
</span>
|
||||
|
||||
@@ -3,20 +3,21 @@
|
||||
|
||||
interface Props {
|
||||
class?: ClassNameValue;
|
||||
size?: string | number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
let { class: classes = "", width = "24", height = "24" }: Props = $props();
|
||||
let { class: classes = "", size = "24px", width = size, height = size }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 32 32"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={twMerge("text-secondary", classes)}
|
||||
class={twMerge(classes)}
|
||||
>
|
||||
<path
|
||||
d="M4.5 7H6.5M4.5 5H9.5M1.5 2.5V12.5L4 10.5H11.5C12.0523 10.5 12.5 10.0523 12.5 9.5V2.5C12.5 1.94772 12.0523 1.5 11.5 1.5H2.5C1.94772 1.5 1.5 1.94772 1.5 2.5Z"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { twMerge, type ClassNameValue } from "tailwind-merge";
|
||||
|
||||
interface Props {
|
||||
width?: string;
|
||||
height?: string;
|
||||
class?: ClassNameValue;
|
||||
}
|
||||
|
||||
let { width = "140", height = "140", class: classes = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={twMerge("", classes)}
|
||||
>
|
||||
<rect width="64" height="64" rx="8" fill="var(--color-variant1)" />
|
||||
<path
|
||||
d="M32.3156 14.5034C32.4028 14.4851 32.4119 14.5444 32.418 14.6067C33.8836 19.1802 36.0055 22.1742 38.761 25.2792C41.3975 28.2497 44.4463 31.0628 44.6956 36.2596C44.8912 40.3436 43.108 43.6454 41.2172 45.7C39.1074 47.9939 35.682 49.5727 32.111 49.4974C28.4401 49.4191 25.4353 47.7956 23.3118 45.3929C21.1967 43.0001 19.7432 39.9711 20.038 35.8484C20.4707 29.7889 25.0101 26.8298 27.9164 23.0217C29.8057 20.5438 31.1243 17.9808 32.3156 14.5034ZM24.7441 37.0813C24.7805 39.8472 26.2719 42.3723 28.3256 43.6492C31.2228 45.4499 35.253 44.9885 37.6364 42.6216C38.8944 41.3735 39.7485 39.6314 39.9895 37.0813C36.8081 37.0813 36.0821 37.0341 32.3854 37.0341C32.3854 34.5022 32.4187 31.8123 32.4187 29.2819C27.9983 29.2705 24.6903 32.925 24.7441 37.0813Z"
|
||||
fill="var(--color-secondary)"
|
||||
/>
|
||||
</svg>
|
||||
@@ -23,7 +23,7 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
"overflow-hidden rounded-2xl border transition-all duration-300",
|
||||
open ? "border-grey bg-soft-purple shadow-md" : "border-grey bg-white shadow-sm",
|
||||
open ? "border-grey bg-purple-soft shadow-md" : "border-grey bg-white shadow-sm",
|
||||
classes,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const styles = {
|
||||
default: "inset-ring-1 hover:inset-ring-0 inset-ring-secondary bg-white",
|
||||
default: "inset-ring-1 hover:inset-ring-0 inset-ring-secondary bg-white text-secondary",
|
||||
active: "bg-secondary text-primary hover:text-secondary",
|
||||
ghost: "",
|
||||
};
|
||||
@@ -27,7 +27,7 @@
|
||||
<button
|
||||
{disabled}
|
||||
class={twMerge(
|
||||
"hover:bg-variant1 w-auto rounded-4xl px-4 py-2 font-bold",
|
||||
"hover:bg-variant1 w-auto overflow-hidden rounded-4xl px-4 py-2 text-base font-bold text-ellipsis transition-all duration-300 ease-in-out",
|
||||
styles[type],
|
||||
classes,
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
onBlur = undefined,
|
||||
onInput = undefined,
|
||||
}: {
|
||||
value?: Date;
|
||||
value: Date;
|
||||
id?: string;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
@@ -28,7 +28,7 @@
|
||||
helperText?: string;
|
||||
error?: string;
|
||||
onBlur?: () => void;
|
||||
onInput?: (date: Date) => void;
|
||||
onInput?: (date: string) => void;
|
||||
} = $props();
|
||||
|
||||
const generatedId = $props.id();
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
// Call optional input handler
|
||||
if (onInput) {
|
||||
onInput(dateValue);
|
||||
onInput(dateToString(dateValue));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
const maxString = $derived(toDateString(max));
|
||||
|
||||
// Convert current value to string for HTML input
|
||||
const valueString = $derived(dateToString(value));
|
||||
const valueString = $derived(typeof value === "string" ? value : dateToString(value));
|
||||
</script>
|
||||
|
||||
<div class={twMerge("relative", disabled && "opacity-40")}>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="border-variant1 bg-soft-purple flex w-full max-w-109.25 flex-col gap-8 rounded-2xl border p-6 {newMessage
|
||||
class="border-variant1 bg-purple-soft flex w-full max-w-109.25 flex-col gap-8 rounded-2xl border p-6 {newMessage
|
||||
? `shadow-[${newMsgCardShadow}]`
|
||||
: ''}"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { twMerge, type ClassNameValue } from "tailwind-merge";
|
||||
|
||||
import { t } from "../../i18n/store";
|
||||
import SearchIcon from "../../svgs/SearchIcon.svelte";
|
||||
import Close from "../icons/Close.svelte";
|
||||
import SearchIcon from "../icons/Search.svelte";
|
||||
|
||||
interface Props {
|
||||
class?: ClassNameValue;
|
||||
@@ -11,7 +12,11 @@
|
||||
name?: string;
|
||||
id?: string;
|
||||
label?: string;
|
||||
"data-testid"?: string;
|
||||
onsubmit?: (value: string) => void;
|
||||
oninput?: (e: Event) => void;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
onclear?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -21,18 +26,22 @@
|
||||
name = "search",
|
||||
id = "search",
|
||||
label = undefined,
|
||||
"data-testid": dataTestId,
|
||||
onsubmit,
|
||||
oninput,
|
||||
onkeydown,
|
||||
onclear,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
"border-secondary relative flex h-14 w-full items-center justify-between rounded-3xl border bg-white p-4",
|
||||
"relative flex h-14 w-full items-center justify-between rounded-3xl border border-black bg-white p-4",
|
||||
classes,
|
||||
)}
|
||||
>
|
||||
{#if label !== undefined}
|
||||
<label for={id} class="absolute -top-3 left-3 bg-white px-1 text-sm text-gray-700">
|
||||
{#if label !== undefined && value}
|
||||
<label for={id} class="absolute -top-3 left-3 bg-white px-1 text-sm text-black">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
@@ -43,16 +52,25 @@
|
||||
{id}
|
||||
{placeholder}
|
||||
bind:value
|
||||
{oninput}
|
||||
{onkeydown}
|
||||
data-testid={dataTestId}
|
||||
class="flex-1 border-none bg-white text-black outline-none focus:ring-0"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (onsubmit) onsubmit(value);
|
||||
}}
|
||||
class="text-secondary cursor-pointer"
|
||||
>
|
||||
<SearchIcon class="pointer-events-none h-6 w-6" />
|
||||
</button>
|
||||
{#if value && onclear}
|
||||
<button type="button" onclick={onclear} class="text-secondary">
|
||||
<Close class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (onsubmit) onsubmit(value);
|
||||
}}
|
||||
class="text-secondary"
|
||||
>
|
||||
<SearchIcon class="h-6 w-6" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<Tooltip
|
||||
text={$t("contributions.tootip.copied")}
|
||||
text={$t("common.tooltip.coved")}
|
||||
tooltipClass="bg-secondary -translate-x-[50%]"
|
||||
className="cursor-copy shrink-0 w-auto h-auto"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Modal } from "flowbite-svelte";
|
||||
import { twMerge, type ClassNameValue } from "tailwind-merge";
|
||||
|
||||
import CopyUrl from "./CopyUrl.svelte";
|
||||
import Facebook from "./Facebook.svelte";
|
||||
@@ -10,31 +11,49 @@
|
||||
|
||||
interface Props {
|
||||
shareText?: string;
|
||||
variant?: "blog" | "project";
|
||||
variant?: "blog" | "project" | "profile";
|
||||
projectSlug?: string;
|
||||
url?: string;
|
||||
buttonClass?: ClassNameValue;
|
||||
}
|
||||
|
||||
let { shareText = "", variant = "project", projectSlug = "", url = "" }: Props = $props();
|
||||
let {
|
||||
shareText = "",
|
||||
variant = "project",
|
||||
projectSlug = "",
|
||||
url = "",
|
||||
buttonClass = "",
|
||||
}: Props = $props();
|
||||
|
||||
const widgetUrl = $derived(`https://www.goteo.org/widget/project/${projectSlug}`);
|
||||
const widgetUrl = $derived(
|
||||
variant === "project" ? `https://www.goteo.org/widget/project/${projectSlug}` : "",
|
||||
);
|
||||
|
||||
let openModal = $state(false);
|
||||
|
||||
const modalTitle = $derived(
|
||||
variant === "blog" ? $t("blog.share.modal.title") : $t("project.share.modal.title"),
|
||||
variant === "blog"
|
||||
? $t("blog.share.modal.title")
|
||||
: variant === "profile"
|
||||
? $t("profile.shareModal.title")
|
||||
: $t("project.share.modal.title"),
|
||||
);
|
||||
|
||||
const modalDescription = $derived(
|
||||
variant === "blog"
|
||||
? $t("blog.share.modal.description")
|
||||
: $t("project.share.modal.description"),
|
||||
: variant === "profile"
|
||||
? ""
|
||||
: $t("project.share.modal.description"),
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={() => (openModal = true)}
|
||||
class="text-secondary flex cursor-pointer flex-row items-center gap-2 p-2 font-bold"
|
||||
class={twMerge(
|
||||
"text-secondary flex cursor-pointer flex-row items-center gap-2 p-2 font-bold",
|
||||
buttonClass,
|
||||
)}
|
||||
>
|
||||
<ShareIcon />
|
||||
{$t("project.actions.share")}
|
||||
@@ -47,7 +66,9 @@
|
||||
title={modalTitle}
|
||||
headerClass="py-2 text-secondary text-2xl"
|
||||
>
|
||||
<p>{modalDescription}</p>
|
||||
{#if modalDescription}
|
||||
<p>{modalDescription}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row items-center justify-center gap-6">
|
||||
<CopyUrl {url} />
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
labelText = undefined,
|
||||
helperText = undefined,
|
||||
error = undefined,
|
||||
onInput = undefined,
|
||||
onBlur = undefined,
|
||||
}: {
|
||||
value?: string;
|
||||
value?: string | undefined;
|
||||
id?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
@@ -25,6 +27,8 @@
|
||||
labelText?: string;
|
||||
helperText?: string;
|
||||
error?: string;
|
||||
onInput?: (event: Event) => void;
|
||||
onBlur?: (event?: FocusEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const generatedId = $props.id();
|
||||
@@ -35,7 +39,7 @@
|
||||
{#if labelText}
|
||||
<label
|
||||
for={finalId}
|
||||
class="absolute -top-2 left-4 -translate-y-1/2 transform text-[0.625rem] font-medium text-gray-500 transition-all"
|
||||
class="text-secondary absolute top-0 left-4 -translate-y-1/2 transform bg-white px-1 text-sm font-medium transition-all"
|
||||
>
|
||||
{labelText}
|
||||
</label>
|
||||
@@ -43,13 +47,15 @@
|
||||
<input
|
||||
bind:value
|
||||
id={finalId}
|
||||
onblur={onBlur}
|
||||
oninput={onInput}
|
||||
{name}
|
||||
{type}
|
||||
{required}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
class={twMerge(
|
||||
"peer border-secondary focus:ring-tertiary w-full rounded-md border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none",
|
||||
"peer border-secondary focus:ring-tertiary w-full rounded-lg border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
classes,
|
||||
)}
|
||||
|
||||
@@ -5,93 +5,81 @@
|
||||
import LinkedinIcon from "../../svgs/LinkedinIcon.svelte";
|
||||
import MediumIcon from "../../svgs/MediumIcon.svelte";
|
||||
import XIcon from "../../svgs/XIcon.svelte";
|
||||
import MapIcon from "../icons/Location.svelte";
|
||||
import TerritoryTag from "../TerritoryTag.svelte";
|
||||
|
||||
import type { Link, Territory } from "../../openapi/client/types.gen";
|
||||
|
||||
interface Props {
|
||||
displayName: string;
|
||||
location?: string;
|
||||
socialLinks?: {
|
||||
twitter?: string;
|
||||
instagram?: string;
|
||||
facebook?: string;
|
||||
linkedin?: string;
|
||||
email?: string;
|
||||
medium?: string;
|
||||
website?: string;
|
||||
};
|
||||
territory?: Territory;
|
||||
links?: Link[];
|
||||
email?: string;
|
||||
}
|
||||
|
||||
let { displayName, location, socialLinks = {} }: Props = $props();
|
||||
let { displayName, territory, links = [], email }: Props = $props();
|
||||
|
||||
type SocialLinkKey = "email" | "facebook" | "instagram" | "linkedin" | "medium" | "twitter";
|
||||
|
||||
interface SocialLink {
|
||||
name: string;
|
||||
url: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
}
|
||||
|
||||
const allSocialLinks: SocialLink[] = [
|
||||
{
|
||||
name: "twitter",
|
||||
url: socialLinks.twitter || "",
|
||||
label: "X/Twitter",
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
name: "instagram",
|
||||
url: socialLinks.instagram || "",
|
||||
label: "Instagram",
|
||||
icon: InstagramIcon,
|
||||
},
|
||||
{
|
||||
name: "facebook",
|
||||
url: socialLinks.facebook || "",
|
||||
label: "Facebook",
|
||||
icon: FacebookIcon,
|
||||
},
|
||||
{
|
||||
name: "linkedin",
|
||||
url: socialLinks.linkedin || "",
|
||||
label: "LinkedIn",
|
||||
icon: LinkedinIcon,
|
||||
},
|
||||
{
|
||||
name: "gmail",
|
||||
url: socialLinks.email || "",
|
||||
label: "Email",
|
||||
icon: GmailIcon,
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
url: socialLinks.medium || "",
|
||||
label: "Medium",
|
||||
icon: MediumIcon,
|
||||
},
|
||||
];
|
||||
function detectSocialPlatform(link: Link): SocialLinkKey | null {
|
||||
const url = (link.url ?? "").toLowerCase();
|
||||
const rel = (link.rel ?? "").toLowerCase();
|
||||
if (rel === "twitter" || url.includes("twitter.com") || url.includes("x.com"))
|
||||
return "twitter";
|
||||
if (rel === "instagram" || url.includes("instagram.com")) return "instagram";
|
||||
if (rel === "facebook" || url.includes("facebook.com")) return "facebook";
|
||||
if (rel === "linkedin" || url.includes("linkedin.com")) return "linkedin";
|
||||
if (rel === "medium" || url.includes("medium.com")) return "medium";
|
||||
return null;
|
||||
}
|
||||
|
||||
const socialMediaLinks = $derived(allSocialLinks.filter((link) => link.url));
|
||||
const resolvedLinks = $derived(
|
||||
links.reduce<Record<string, string>>(
|
||||
(acc, link) => {
|
||||
const platform = detectSocialPlatform(link);
|
||||
if (platform && link.url && !acc[platform]) acc[platform] = link.url;
|
||||
return acc;
|
||||
},
|
||||
email ? { email: `mailto:${email}` } : {},
|
||||
),
|
||||
);
|
||||
|
||||
const allSocialLinks: Record<SocialLinkKey, SocialLink> = $derived({
|
||||
email: { url: resolvedLinks.email || "", label: "Email", icon: GmailIcon },
|
||||
facebook: { url: resolvedLinks.facebook || "", label: "Facebook", icon: FacebookIcon },
|
||||
instagram: { url: resolvedLinks.instagram || "", label: "Instagram", icon: InstagramIcon },
|
||||
linkedin: { url: resolvedLinks.linkedin || "", label: "LinkedIn", icon: LinkedinIcon },
|
||||
medium: { url: resolvedLinks.medium || "", label: "Medium", icon: MediumIcon },
|
||||
twitter: { url: resolvedLinks.twitter || "", label: "X/Twitter", icon: XIcon },
|
||||
});
|
||||
|
||||
const socialMediaLinks = $derived(
|
||||
(Object.entries(allSocialLinks) as [SocialLinkKey, SocialLink][]).filter(
|
||||
([, link]) => link.url,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mt-32 flex w-full flex-col items-center gap-4">
|
||||
<div class="mt-28 flex w-full flex-col items-center gap-4">
|
||||
<!-- Name -->
|
||||
<h1 class="text-secondary text-2xl leading-tight font-bold">
|
||||
{displayName}
|
||||
</h1>
|
||||
|
||||
<!-- Location -->
|
||||
{#if location}
|
||||
<div class="flex items-center gap-1">
|
||||
<MapIcon class="text-secondary size-8" />
|
||||
<span class="text-secondary text-base leading-normal">
|
||||
{location}
|
||||
</span>
|
||||
</div>
|
||||
{#if territory}
|
||||
<TerritoryTag {territory} class="border-0 bg-transparent" />
|
||||
{/if}
|
||||
|
||||
<!-- Social Media Links -->
|
||||
{#if socialMediaLinks.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
{#each socialMediaLinks as link}
|
||||
{#each socialMediaLinks as [, link]}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Bullet from "../../components/icons/Bullet.svelte";
|
||||
import { locale } from "../../i18n/store";
|
||||
import { type ProjectUpdate } from "../../openapi/client/index";
|
||||
import { formatDate } from "../../utils/dates.ts";
|
||||
|
||||
import type { ProjectUpdateCardType } from "./ProjectUpdateCard.svelte";
|
||||
|
||||
const cardStyles = {
|
||||
contracted: "h-[24.5rem] w-[21.75rem]",
|
||||
expanded: "h-[24.5rem] w-[30.75rem]",
|
||||
mobile: "h-[25.625rem] w-[13.5rem]",
|
||||
};
|
||||
|
||||
const backgroundSvgStyles = {
|
||||
contracted: "-right-40 -bottom-28 h-[34rem] w-[34rem]",
|
||||
expanded: "-right-24 -bottom-28 h-[34rem] w-[34rem]",
|
||||
mobile: "-right-52 -bottom-10 h-[31rem] w-[31rem]",
|
||||
};
|
||||
|
||||
const titleStyles = {
|
||||
contracted: "text-double leading-10",
|
||||
expanded: "text-double leading-10",
|
||||
mobile: "text-2xl leading-8",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
update: ProjectUpdate;
|
||||
type?: ProjectUpdateCardType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
let { update, type = "contracted", isActive }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
"flex shrink-0 flex-col rounded-[1.25rem] border border-[#E7E1F1] bg-[#FCFAFF] p-4 shadow-sm transition-[width,opacity,box-shadow] duration-300 ease-out",
|
||||
cardStyles[type],
|
||||
type === "contracted" && !isActive && "opacity-70",
|
||||
isActive && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<div class="text-secondary flex shrink-0 flex-row gap-0.5 text-lg leading-6 font-bold">
|
||||
{#if update.date}
|
||||
{formatDate(new Date(update.date), $locale)}
|
||||
{/if}
|
||||
<div class="pt-0.5">
|
||||
<Bullet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex min-h-0 flex-1">
|
||||
<div
|
||||
class="relative isolate flex h-full min-h-0 overflow-hidden rounded-2xl bg-[#FF4ED1] p-4 text-white"
|
||||
>
|
||||
<svg
|
||||
class={twMerge(
|
||||
"pointer-events-none absolute z-0 max-w-none text-black",
|
||||
backgroundSvgStyles[type],
|
||||
)}
|
||||
viewBox="0 0 535 533"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.08"
|
||||
d="M339.41 -307.908C341.812 -308.41 342.063 -306.776 342.23 -305.059C382.622 -179.016 441.101 -96.5034 517.041 -10.932C589.701 70.9316 673.723 148.459 680.594 291.679C685.983 404.231 636.839 495.227 584.73 551.849C526.585 615.069 432.183 658.578 333.771 656.504C232.603 654.346 149.792 609.602 91.271 543.386C32.9799 477.443 -7.07829 393.966 1.04613 280.346C12.9717 113.351 138.075 31.8013 218.171 -73.1467C270.238 -141.436 306.579 -212.072 339.41 -307.908ZM130.744 314.323C131.747 390.552 172.849 460.14 229.449 495.332C309.294 544.957 420.362 532.242 486.047 467.011C520.717 432.615 544.254 384.603 550.896 314.323C463.219 314.323 443.211 313.024 341.332 313.024C341.332 243.247 342.251 169.114 342.251 99.3786C220.426 99.0644 129.262 199.781 130.744 314.323Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<h2 class={twMerge("relative z-10 font-bold text-white", titleStyles[type])}>
|
||||
{update.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,6 +9,7 @@
|
||||
import { t } from "../../i18n/store";
|
||||
import { apiProjectSupportsGetCollection } from "../../openapi/client/index";
|
||||
import Loader from "../../svgs/Loader.svelte";
|
||||
import ActionableButton from "../library/ActionableButton.svelte";
|
||||
import Grid from "../library/Grid.svelte";
|
||||
|
||||
import type { Accounting, Project, ProjectSupport } from "../../openapi/client/index";
|
||||
@@ -21,7 +22,7 @@
|
||||
accounting: Accounting;
|
||||
} = $props();
|
||||
|
||||
const projectId = $derived(project.id!.toString());
|
||||
const projectId = $derived(project.id?.toString());
|
||||
|
||||
let projectsSupportItems = $state<ProjectSupport[]>([]);
|
||||
|
||||
@@ -29,20 +30,13 @@
|
||||
|
||||
let isLoaded = $state(false);
|
||||
let openModal = $state(false);
|
||||
|
||||
function getSupportType(item: (typeof projectsSupportItems)[number]) {
|
||||
switch (true) {
|
||||
case item.matchfunding:
|
||||
return "matchfunding";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
let hasMore = $state(false);
|
||||
let currentPage = $state(1);
|
||||
|
||||
const groupedItems = $derived.by(() =>
|
||||
projectsSupportItems.reduce(
|
||||
(groups, item) => {
|
||||
const type = getSupportType(item);
|
||||
const type = item.matchfunding ? "matchfunding" : "default";
|
||||
(groups[type] ??= []).push(item);
|
||||
return groups;
|
||||
},
|
||||
@@ -50,14 +44,47 @@
|
||||
),
|
||||
);
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const FIRST_PAGE_SIZE = 29;
|
||||
|
||||
async function fetchPage(page: number): Promise<ProjectSupport[]> {
|
||||
const itemsPerPage = page === 1 ? FIRST_PAGE_SIZE : PAGE_SIZE;
|
||||
const { data } = await apiProjectSupportsGetCollection({
|
||||
query: { project: projectId, anonymous: false, page, itemsPerPage },
|
||||
});
|
||||
|
||||
const items = (data as ProjectSupport[]) ?? [];
|
||||
hasMore = items.length === itemsPerPage;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const matchfundingCount = $derived(groupedItems.matchfunding?.length ?? 0);
|
||||
const hasMatchfunding = $derived(matchfundingCount > 0);
|
||||
const isSingleMatchfunding = $derived(matchfundingCount === 1);
|
||||
|
||||
const visibleDefaultItems = $derived(
|
||||
hasMatchfunding
|
||||
? groupedItems.default
|
||||
: groupedItems.default?.slice(
|
||||
0,
|
||||
PAGE_SIZE - 2 + Math.max(0, (currentPage - 1) * PAGE_SIZE),
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
apiProjectSupportsGetCollection({
|
||||
query: { project: projectId, anonymous: false },
|
||||
}).then(({ data: publicSupports }) => {
|
||||
projectsSupportItems = publicSupports || [];
|
||||
fetchPage(1).then((items) => {
|
||||
projectsSupportItems = items;
|
||||
isLoaded = true;
|
||||
});
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
const nextPage = currentPage + 1;
|
||||
const supports = await fetchPage(nextPage);
|
||||
projectsSupportItems = [...projectsSupportItems, ...supports];
|
||||
currentPage = nextPage;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-10">
|
||||
@@ -70,20 +97,32 @@
|
||||
{$t("pages.project.view.tabs.community.content.title")}
|
||||
</h2>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Grid class="grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{#each groupedItems.matchfunding as item (item.id)}
|
||||
<ProjectCommunityMatchfunding
|
||||
{item}
|
||||
bind:openModal
|
||||
bind:selectedProjectSupport
|
||||
/>
|
||||
{/each}
|
||||
<ProjectCommunityAnonymous {project} currency={accounting.balance?.currency!} />
|
||||
</Grid>
|
||||
{#if hasMatchfunding}
|
||||
<Grid
|
||||
class={`grid-cols-1 gap-6 ${isSingleMatchfunding ? "md:grid-cols-2 lg:grid-cols-2" : "md:grid-cols-3"}`}
|
||||
>
|
||||
{#each groupedItems.matchfunding as item (item.id)}
|
||||
<ProjectCommunityMatchfunding
|
||||
{item}
|
||||
bind:openModal
|
||||
bind:selectedProjectSupport
|
||||
/>
|
||||
{/each}
|
||||
<ProjectCommunityAnonymous {project} currency={accounting.balance?.currency!} />
|
||||
</Grid>
|
||||
{/if}
|
||||
|
||||
{#if groupedItems.default?.length}
|
||||
<Grid class="grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each groupedItems.default as item (item.id)}
|
||||
{#if !hasMatchfunding}
|
||||
<div class="row-span-2 h-full">
|
||||
<ProjectCommunityAnonymous
|
||||
{project}
|
||||
currency={accounting.balance?.currency!}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#each visibleDefaultItems as item (item.id)}
|
||||
<ProjectCommunityMessage
|
||||
{item}
|
||||
bind:openModal
|
||||
@@ -92,6 +131,14 @@
|
||||
{/each}
|
||||
</Grid>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="flex w-full justify-center">
|
||||
<ActionableButton action={loadMore} autoreset={0} class="w-32">
|
||||
{$t("pages.project.view.tabs.community.loadMore")}
|
||||
</ActionableButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { Modal } from "flowbite-svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import ProjectUpdateCard from "./ProjectUpdateCard.svelte";
|
||||
import PlatformUpdateCard from "./PlatformUpdateCard.svelte";
|
||||
import ProjectUpdateCard, { type ProjectUpdateCardType } from "./ProjectUpdateCard.svelte";
|
||||
import { t } from "../../i18n/store";
|
||||
import { apiProjectUpdatesGetCollection } from "../../openapi/client/index";
|
||||
import AlertIcon from "../../svgs/AlertIcon.svelte";
|
||||
@@ -23,14 +24,14 @@
|
||||
|
||||
const projectId = project.id!.toString();
|
||||
|
||||
let projectsUpdates: ProjectUpdate[] = $state([]);
|
||||
let projectUpdates: ProjectUpdate[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
apiProjectUpdatesGetCollection({
|
||||
query: { project: projectId, "order[date]": "asc" },
|
||||
query: { project: projectId, "order[date]": "desc" },
|
||||
headers: { "Accept-Language": lang },
|
||||
}).then((data) => {
|
||||
projectsUpdates = data.data!;
|
||||
projectUpdates = data.data!;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +39,6 @@
|
||||
let openModal = $state(false);
|
||||
let selected: ProjectUpdate | null = $state(null);
|
||||
let activeCard: number = $state(0);
|
||||
let cardType: "small" | "large" = $state("small");
|
||||
|
||||
$effect(() => {
|
||||
if (openModal) cleanCloseButton();
|
||||
@@ -60,6 +60,12 @@
|
||||
itemsPerGroup = isMobile ? 1 : 2;
|
||||
}
|
||||
|
||||
function getCardType(index: number): ProjectUpdateCardType {
|
||||
if (itemsPerGroup === 1) return "mobile";
|
||||
|
||||
return index === activeCard ? "expanded" : "contracted";
|
||||
}
|
||||
|
||||
function cleanCloseButton() {
|
||||
const closeBtn = document.querySelector('button[aria-label="Close"]');
|
||||
if (!closeBtn) return;
|
||||
@@ -101,8 +107,19 @@
|
||||
<h2 class="text-secondary line-clamp-2 flex max-w-2xl text-4xl font-bold">
|
||||
{$t("pages.project.view.tabs.updates.content.title")}
|
||||
</h2>
|
||||
<Carousel bind:activeCard gap={24} showDots={true} {itemsPerGroup}>
|
||||
{#if projectsUpdates.length === 0}
|
||||
<Carousel
|
||||
bind:activeCard
|
||||
gap={24}
|
||||
showDots={true}
|
||||
{itemsPerGroup}
|
||||
dotsPerItem={true}
|
||||
lockItemWidth={itemsPerGroup === 1}
|
||||
disableDrag={itemsPerGroup !== 1}
|
||||
centerNavButtons={true}
|
||||
mobileItemsToShow={1}
|
||||
desktopItemsToShow={2}
|
||||
>
|
||||
{#if projectUpdates.length === 0}
|
||||
<div
|
||||
class="flex h-35 w-full items-center justify-center rounded bg-indigo-100 font-bold"
|
||||
>
|
||||
@@ -110,20 +127,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- {#snippet onActiveChange(group: number)}
|
||||
{(activeGroup = group)}
|
||||
{/snippet} -->
|
||||
|
||||
{#each projectsUpdates as update, i}
|
||||
<ProjectUpdateCard
|
||||
{update}
|
||||
type={cardType}
|
||||
isActive={i === activeCard}
|
||||
onClick={(): void => {
|
||||
selected = update;
|
||||
openModal = true;
|
||||
}}
|
||||
/>
|
||||
{#each projectUpdates as update, i}
|
||||
{#if !update.author}
|
||||
<PlatformUpdateCard {update} type={getCardType(i)} isActive={i === activeCard} />
|
||||
{:else}
|
||||
<ProjectUpdateCard
|
||||
{update}
|
||||
type={getCardType(i)}
|
||||
isActive={i === activeCard}
|
||||
onClick={(): void => {
|
||||
selected = update;
|
||||
openModal = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</Carousel>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Bullet from "../../components/icons/Bullet.svelte";
|
||||
@@ -14,16 +13,71 @@
|
||||
import type { User } from "../../openapi/client/types.gen.ts";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
export type ProjectUpdateCardType = "contracted" | "expanded" | "mobile";
|
||||
|
||||
const cardStyles = {
|
||||
contracted: "h-[24.5rem] w-[21.75rem] gap-6",
|
||||
expanded: "h-[24.5rem] w-[30.75rem] gap-6",
|
||||
mobile: "h-[25.625rem] w-[13.5rem] gap-3",
|
||||
};
|
||||
|
||||
const mediaGroupStyles = {
|
||||
contracted: "flex flex-col gap-4",
|
||||
expanded: "flex shrink-0 flex-col gap-4",
|
||||
mobile: "flex shrink-0 flex-col gap-3",
|
||||
};
|
||||
|
||||
const dateStyles = {
|
||||
contracted: "shrink-0 text-2xl",
|
||||
expanded: "text-2xl",
|
||||
mobile: "text-2xl",
|
||||
};
|
||||
|
||||
const imageStyles = {
|
||||
contracted: "rounded-3xl",
|
||||
expanded: "rounded-3xl",
|
||||
mobile: "rounded-2xl",
|
||||
};
|
||||
|
||||
const contentGroupStyles = {
|
||||
contracted: "gap-4",
|
||||
expanded: "gap-4",
|
||||
mobile: "gap-2",
|
||||
};
|
||||
|
||||
const titleStyles = {
|
||||
contracted: "line-clamp-1 text-double leading-7",
|
||||
expanded: "line-clamp-1 text-xl leading-7",
|
||||
mobile: "line-clamp-2 text-lg leading-5",
|
||||
};
|
||||
|
||||
const bodyStyles = {
|
||||
contracted: "text-base text-ellipsis",
|
||||
expanded: "text-base text-ellipsis",
|
||||
mobile: "text-[0.6875rem]",
|
||||
};
|
||||
|
||||
const footerStyles = {
|
||||
contracted: "items-end justify-between gap-3",
|
||||
expanded: "items-end justify-between gap-3",
|
||||
mobile: "flex-col gap-3",
|
||||
};
|
||||
|
||||
const authorStyles = {
|
||||
contracted: "line-clamp-1 text-xs",
|
||||
expanded: "text-xs",
|
||||
mobile: "line-clamp-1 text-center text-[0.6875rem]",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
update: ProjectUpdate;
|
||||
type?: "small" | "large";
|
||||
type?: ProjectUpdateCardType;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement> | undefined;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
let { update, type, onClick, isActive }: Props = $props();
|
||||
let { update, type = "contracted", onClick, isActive }: Props = $props();
|
||||
|
||||
let cardClasses = $state("");
|
||||
let author: User | undefined = $state(undefined);
|
||||
|
||||
async function getAuthor(update: ProjectUpdate): Promise<User | undefined> {
|
||||
@@ -41,106 +95,68 @@
|
||||
return user;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
author = await getAuthor(update);
|
||||
type = author === undefined ? "small" : "large";
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
isActive;
|
||||
cardClasses = isActive ? "opacity-100" : "";
|
||||
getAuthor(update).then((data) => (author = data));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if type === "small"}
|
||||
<div
|
||||
class={twMerge(
|
||||
"flex w-138.5 flex-col gap-6 rounded-4xl bg-white p-6 opacity-48",
|
||||
cardClasses,
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-secondary flex flex-row gap-0.5 text-2xl font-bold">
|
||||
{#if update.date}
|
||||
{formatDate(new Date(update.date), $locale)}
|
||||
{/if}
|
||||
<div class="pt-1">
|
||||
<Bullet />
|
||||
</div>
|
||||
<div
|
||||
class={twMerge(
|
||||
"flex shrink-0 flex-col overflow-hidden rounded-[1.25rem] border border-[#E7E1F1] bg-[#FCFAFF] p-4 shadow-sm transition-[width,opacity,box-shadow] duration-300 ease-out",
|
||||
cardStyles[type],
|
||||
isActive && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<div class={mediaGroupStyles[type]}>
|
||||
<div
|
||||
class={twMerge(
|
||||
"text-secondary flex flex-row gap-0.5 leading-6 font-bold",
|
||||
dateStyles[type],
|
||||
)}
|
||||
>
|
||||
{#if update.date}
|
||||
{formatDate(new Date(update.date), $locale)}
|
||||
{/if}
|
||||
<div class="pt-0.5">
|
||||
<Bullet />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-tertiary relative flex h-full flex-col gap-4 overflow-hidden rounded-3xl bg-cover p-5"
|
||||
>
|
||||
<h2 class="font-body text-[2.5rem] leading-12 font-bold text-ellipsis text-white">
|
||||
{#if update.cover}
|
||||
<img
|
||||
src={update.cover}
|
||||
alt={update.title}
|
||||
class={twMerge(
|
||||
"no-select h-[268.6px] w-full shrink-0 self-stretch object-cover",
|
||||
imageStyles[type],
|
||||
)}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col justify-between gap-6">
|
||||
<div class={twMerge("flex flex-col", contentGroupStyles[type])}>
|
||||
<h2 class={twMerge("text-secondary font-bold", titleStyles[type])}>
|
||||
{update.title}
|
||||
</h2>
|
||||
<svg
|
||||
class="absolute -top-77 left-24.75"
|
||||
width="681"
|
||||
height="964.575"
|
||||
viewBox="0 0 407 533"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
class="fill-black"
|
||||
opacity="0.08"
|
||||
d="M339.41 -307.908C341.812 -308.41 342.063 -306.776 342.23 -305.059C382.622 -179.016 441.101 -96.5034 517.041 -10.932C589.701 70.9316 673.723 148.459 680.594 291.679C685.983 404.231 636.839 495.227 584.73 551.849C526.585 615.069 432.183 658.578 333.771 656.504C232.603 654.346 149.792 609.602 91.271 543.386C32.9799 477.443 -7.07829 393.966 1.04613 280.346C12.9717 113.351 138.075 31.8013 218.171 -73.1467C270.238 -141.436 306.579 -212.072 339.41 -307.908ZM130.744 314.323C131.747 390.552 172.849 460.14 229.449 495.332C309.294 544.957 420.362 532.242 486.047 467.011C520.717 432.615 544.254 384.603 550.896 314.323C463.219 314.323 443.211 313.024 341.332 313.024C341.332 243.247 342.251 169.114 342.251 99.3786C220.426 99.0644 129.262 199.781 130.744 314.323Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === "large"}
|
||||
<div
|
||||
class={twMerge(
|
||||
"bg-purple-soft border-variant1 flex w-[49.063rem] flex-col gap-6 rounded-4xl border p-6 shadow-sm",
|
||||
cardClasses,
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-secondary flex flex-row gap-0.5 text-2xl font-bold">
|
||||
{#if update.date}
|
||||
{formatDate(new Date(update.date), $locale)}
|
||||
{/if}
|
||||
<div class="pt-1">
|
||||
<Bullet />
|
||||
{#if update.subtitle || update.body}
|
||||
<div class="flex flex-col gap-2 leading-5">
|
||||
<p class="line-clamp-2 text-base font-bold text-black">{update.subtitle}</p>
|
||||
<p class={twMerge("text-content line-clamp-2 font-normal", bodyStyles[type])}>
|
||||
{#await renderMarkdown(update.body) then content}
|
||||
{@html content}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if update.cover}
|
||||
<img
|
||||
src={update.cover}
|
||||
alt={update.title}
|
||||
class="no-select h-[16.8rem] shrink-0 self-stretch rounded-3xl"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-secondary text-[2rem] leading-10 font-bold">{update.title}</h2>
|
||||
{#if update.subtitle || update.body}
|
||||
<div class="flex flex-col gap-2 leading-6">
|
||||
<p class="text-base font-bold text-black">{update.subtitle}</p>
|
||||
<p
|
||||
class="text-content line-clamp-2 text-base font-normal text-ellipsis ordinal"
|
||||
>
|
||||
{#await renderMarkdown(update.body) then content}
|
||||
{@html content}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex w-full items-end justify-between">
|
||||
<span class="text-content flex text-sm font-medium">
|
||||
{$t("pages.project.view.tabs.updates.by")}
|
||||
<strong class="font-bold text-black"> {author?.displayName}</strong>
|
||||
</span>
|
||||
<Button kind="ghost" onclick={onClick}>
|
||||
{$t("pages.project.view.tabs.updates.content.btn.readMore")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class={twMerge("flex w-full", footerStyles[type])}>
|
||||
<span class={twMerge("text-content font-medium", authorStyles[type])}>
|
||||
{$t("pages.project.view.tabs.updates.by")}
|
||||
<strong class="font-bold text-black"> {author?.displayName}</strong>
|
||||
</span>
|
||||
<Button kind="ghost" onclick={onClick}>
|
||||
{$t("pages.project.view.tabs.updates.content.btn.readMore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,24 +2,27 @@
|
||||
import BudgetModal from "./BudgetModal.svelte";
|
||||
import CreateCard from "./CreateCard.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { apiProjectsGetCollectionUrl } from "../../../openapi/client/paths.gen";
|
||||
import {
|
||||
addBudgetItem,
|
||||
deleteBudgetItem,
|
||||
updateBudgetItem,
|
||||
validationErrors,
|
||||
} from "../../../stores/wizard-state";
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import { budgetTypeClasses } from "../../../utils/budgetColors";
|
||||
import { formatCurrency } from "../../../utils/currencies";
|
||||
import Button from "../../library/Button.svelte";
|
||||
|
||||
import type { ProjectBudgetItem } from "../../../openapi/client";
|
||||
import type { Project, ProjectBudgetItem } from "../../../openapi/client";
|
||||
|
||||
let {
|
||||
project,
|
||||
item,
|
||||
index,
|
||||
loading = $bindable(false),
|
||||
isCreateCard = false,
|
||||
}: {
|
||||
project: Project;
|
||||
item: ProjectBudgetItem | null;
|
||||
index?: number;
|
||||
loading: boolean;
|
||||
@@ -31,12 +34,17 @@
|
||||
|
||||
function handleSaveBudgetItem(data: ProjectBudgetItem | null) {
|
||||
if (!data) return;
|
||||
const projectIri = apiProjectsGetCollectionUrl + "/" + (project.slug ?? project.id);
|
||||
const budgetItem = {
|
||||
...data,
|
||||
project: item?.project ?? projectIri,
|
||||
};
|
||||
let errors;
|
||||
|
||||
if (index !== undefined) {
|
||||
errors = updateBudgetItem(index, data);
|
||||
errors = updateBudgetItem(index, budgetItem, item?.deadline);
|
||||
} else {
|
||||
errors = addBudgetItem(data);
|
||||
errors = addBudgetItem(budgetItem);
|
||||
}
|
||||
|
||||
if (errors === undefined) {
|
||||
@@ -67,6 +75,7 @@
|
||||
title={$t("pages.project.edit.budget.add.title")}
|
||||
description={$t("pages.project.edit.budget.add.description")}
|
||||
variant="budget"
|
||||
{project}
|
||||
onSave={handleSaveBudgetItem}
|
||||
onclick={() => (openModal = true)}
|
||||
bind:open={openModal}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import DeleteModal from "./DeleteModal.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { validationErrors } from "../../../stores/wizard-state";
|
||||
import { validationErrors } from "../../../stores/drafts/projectDraft";
|
||||
import { defaultCurrency } from "../../../utils/currencies";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Toast from "../../library/Toast.svelte";
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import AdminBudgetCard from "./AdminBudgetCard.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { type Project, type ProjectBudgetItem } from "../../../openapi/client";
|
||||
import { validateBudgetAmount } from "../../../stores/drafts/draftValidation";
|
||||
import {
|
||||
currentDraft,
|
||||
navigateToStep,
|
||||
validateBudgetAmount,
|
||||
validationErrors,
|
||||
wizardState,
|
||||
} from "../../../stores/wizard-state";
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import { formatCurrency } from "../../../utils/currencies";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Grid from "../../library/Grid.svelte";
|
||||
@@ -20,8 +20,12 @@
|
||||
project: Project;
|
||||
} = $props();
|
||||
|
||||
let minBudgetItems: ProjectBudgetItem[] = $state($wizardState.budgetItems.minimum);
|
||||
let optBudgetItems: ProjectBudgetItem[] = $state($wizardState.budgetItems.optimum);
|
||||
let minBudgetItems: ProjectBudgetItem[] = $state(
|
||||
$currentDraft?.wizardForm.budgetItems.minimum || [],
|
||||
);
|
||||
let optBudgetItems: ProjectBudgetItem[] = $state(
|
||||
$currentDraft?.wizardForm.budgetItems.optimum || [],
|
||||
);
|
||||
let loading = $state(false);
|
||||
let showErrorToast = $state(false);
|
||||
|
||||
@@ -30,7 +34,9 @@
|
||||
* Simple navigation to next step (6) - validation happens on save/submit
|
||||
*/
|
||||
function handleContinue() {
|
||||
const errors = validateBudgetAmount();
|
||||
if (!$currentDraft) return;
|
||||
|
||||
const errors = validateBudgetAmount($currentDraft);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
validationErrors.set(errors);
|
||||
@@ -43,8 +49,8 @@
|
||||
async function loadBudgetItems() {
|
||||
loading = true;
|
||||
|
||||
minBudgetItems = $wizardState.budgetItems.minimum;
|
||||
optBudgetItems = $wizardState.budgetItems.optimum;
|
||||
minBudgetItems = $currentDraft?.wizardForm.budgetItems.minimum || [];
|
||||
optBudgetItems = $currentDraft?.wizardForm.budgetItems.optimum || [];
|
||||
|
||||
loading = false;
|
||||
}
|
||||
@@ -52,7 +58,7 @@
|
||||
$effect(() => {
|
||||
if (Object.keys($validationErrors).length > 0)
|
||||
console.log("Errores de validación:", $validationErrors);
|
||||
if ($wizardState) {
|
||||
if ($currentDraft) {
|
||||
loadBudgetItems();
|
||||
}
|
||||
});
|
||||
@@ -91,10 +97,10 @@
|
||||
{:else}
|
||||
<Grid class="grid-cols-1 sm:grid-cols-2">
|
||||
{#each minBudgetItems as item, index}
|
||||
<AdminBudgetCard {item} {index} bind:loading />
|
||||
<AdminBudgetCard {project} {item} {index} bind:loading />
|
||||
{/each}
|
||||
|
||||
<AdminBudgetCard isCreateCard={true} item={null} bind:loading />
|
||||
<AdminBudgetCard {project} isCreateCard={true} item={null} bind:loading />
|
||||
</Grid>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -111,10 +117,10 @@
|
||||
{:else}
|
||||
<Grid class="grid-cols-1 sm:grid-cols-2">
|
||||
{#each optBudgetItems as item, i}
|
||||
<AdminBudgetCard {item} index={i} {loading} />
|
||||
<AdminBudgetCard {project} {item} index={i} {loading} />
|
||||
{/each}
|
||||
|
||||
<AdminBudgetCard isCreateCard={true} item={null} {loading} />
|
||||
<AdminBudgetCard {project} isCreateCard={true} item={null} {loading} />
|
||||
</Grid>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,13 @@
|
||||
import RichTextEditor from "./RichTextEditor.svelte";
|
||||
import VideoUrlInput from "./VideoUrlInput.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { validateCampaignInfo } from "../../../stores/drafts/draftValidation";
|
||||
import {
|
||||
wizardState,
|
||||
updateCampaignInfo,
|
||||
currentDraft,
|
||||
navigateToStep,
|
||||
validateCampaignInfo,
|
||||
updateCampaignInfo,
|
||||
type MediaImage,
|
||||
type VideoEmbed,
|
||||
} from "../../../stores/wizard-state";
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Grid from "../../library/Grid.svelte";
|
||||
|
||||
@@ -36,14 +35,24 @@
|
||||
let { onContinue }: CampaignInfoStepProps = $props();
|
||||
|
||||
// Reactive values from store
|
||||
const campaignInfo = $derived($wizardState.campaignInfo);
|
||||
const campaignInfo = $derived(
|
||||
$currentDraft?.wizardForm.campaignInfo ?? {
|
||||
images: [],
|
||||
video: undefined,
|
||||
objectives: "",
|
||||
legacy: "",
|
||||
targetAudience: "",
|
||||
team: "",
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle Continue button
|
||||
* Simple navigation to next step - validation happens on save/submit
|
||||
*/
|
||||
function handleContinue() {
|
||||
const errors = validateCampaignInfo();
|
||||
if (!$currentDraft) return;
|
||||
const errors = validateCampaignInfo($currentDraft.wizardForm);
|
||||
|
||||
if (Object.keys(errors).length === 0) {
|
||||
navigateToStep(3);
|
||||
@@ -63,6 +72,7 @@
|
||||
* Handle image upload
|
||||
*/
|
||||
function handleImageUpload(image: MediaImage) {
|
||||
if (!campaignInfo) return;
|
||||
updateCampaignInfo({
|
||||
images: [...campaignInfo.images, image],
|
||||
});
|
||||
@@ -80,29 +90,37 @@
|
||||
/**
|
||||
* Handle video change
|
||||
*/
|
||||
function handleVideoChange(video: VideoEmbed | null) {
|
||||
updateCampaignInfo({ video });
|
||||
function handleVideoChange(video: string | null) {
|
||||
updateCampaignInfo({
|
||||
video: video ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle objectives change
|
||||
*/
|
||||
function handleObjectivesChange(html: string) {
|
||||
updateCampaignInfo({ objectives: html });
|
||||
updateCampaignInfo({
|
||||
objectives: html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle legacy change
|
||||
*/
|
||||
function handleLegacyChange(html: string) {
|
||||
updateCampaignInfo({ legacy: html });
|
||||
updateCampaignInfo({
|
||||
legacy: html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle target audience change
|
||||
*/
|
||||
function handleTargetAudienceChange(html: string) {
|
||||
updateCampaignInfo({ targetAudience: html });
|
||||
updateCampaignInfo({
|
||||
targetAudience: html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import CollabsCard from "./CollabsCard.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import {
|
||||
navigateToStep,
|
||||
wizardState,
|
||||
type WizardCollaboration,
|
||||
} from "../../../stores/wizard-state";
|
||||
import { currentDraft, navigateToStep } from "../../../stores/drafts/projectDraft";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Grid from "../../library/Grid.svelte";
|
||||
import LoadingSpinner from "../../search/LoadingSpinner.svelte";
|
||||
|
||||
let collabs = $state<WizardCollaboration[]>($wizardState.collaborations);
|
||||
import type { Project, ProjectCollaboration } from "../../../openapi/client";
|
||||
|
||||
let { project }: { project: Project } = $props();
|
||||
|
||||
let collabs = $state<ProjectCollaboration[]>($currentDraft?.wizardForm.collaborations || []);
|
||||
let loading = $state(false);
|
||||
|
||||
/**
|
||||
@@ -24,13 +24,13 @@
|
||||
async function loadCollabs() {
|
||||
loading = true;
|
||||
|
||||
collabs = $wizardState.collaborations;
|
||||
collabs = $currentDraft?.wizardForm.collaborations || [];
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($wizardState) loadCollabs();
|
||||
if ($currentDraft) loadCollabs();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
{:else}
|
||||
<Grid>
|
||||
{#each collabs as collab, index}
|
||||
<CollabsCard {index} {collab} bind:loading />
|
||||
<CollabsCard {project} {index} {collab} bind:loading />
|
||||
{/each}
|
||||
|
||||
<CollabsCard isCreateCard={true} collab={null} bind:loading />
|
||||
<CollabsCard isCreateCard={true} {project} collab={null} bind:loading />
|
||||
</Grid>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -7,18 +7,21 @@
|
||||
deleteCollaboration,
|
||||
updateCollaboration,
|
||||
validationErrors,
|
||||
type WizardCollaboration,
|
||||
} from "../../../stores/wizard-state";
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import { renderMarkdown } from "../../../utils/renderMarkdown";
|
||||
import Button from "../../library/Button.svelte";
|
||||
|
||||
import type { Project, ProjectCollaboration } from "../../../openapi/client";
|
||||
|
||||
let {
|
||||
project,
|
||||
collab,
|
||||
index,
|
||||
loading = $bindable(false),
|
||||
isCreateCard = false,
|
||||
}: {
|
||||
collab: WizardCollaboration | null;
|
||||
project: Project;
|
||||
collab: ProjectCollaboration | null;
|
||||
index?: number;
|
||||
loading: boolean;
|
||||
isCreateCard?: boolean;
|
||||
@@ -27,7 +30,7 @@
|
||||
let openModal = $state(false);
|
||||
let showModalErrorToast = $state(false);
|
||||
|
||||
function handleSaveCollab(data: WizardCollaboration | null) {
|
||||
function handleSaveCollab(data: ProjectCollaboration | null) {
|
||||
if (!data) return;
|
||||
let errors;
|
||||
|
||||
@@ -62,6 +65,7 @@
|
||||
|
||||
{#if isCreateCard}
|
||||
<CreateCard
|
||||
{project}
|
||||
title={$t("pages.project.edit.collaborations.add.title")}
|
||||
description={$t("pages.project.edit.collaborations.add.description")}
|
||||
variant="collab"
|
||||
@@ -95,6 +99,7 @@
|
||||
<CollabsModal
|
||||
bind:open={openModal}
|
||||
bind:showToast={showModalErrorToast}
|
||||
{project}
|
||||
{collab}
|
||||
onSave={handleSaveCollab}
|
||||
onDelete={handleDeleteCollab}
|
||||
|
||||
@@ -3,23 +3,27 @@
|
||||
|
||||
import DeleteModal from "./DeleteModal.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { validationErrors, type WizardCollaboration } from "../../../stores/wizard-state";
|
||||
import { apiProjectsGetCollectionUrl } from "../../../openapi/client/paths.gen";
|
||||
import { validationErrors } from "../../../stores/drafts/projectDraft";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Toast from "../../library/Toast.svelte";
|
||||
|
||||
import type { Project, ProjectCollaboration } from "../../../openapi/client";
|
||||
import type { ClassNameValue } from "tailwind-merge";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
showToast = $bindable(false),
|
||||
project,
|
||||
collab,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
showToast: boolean;
|
||||
collab: WizardCollaboration | null;
|
||||
onSave: (data: WizardCollaboration | null) => void;
|
||||
project: Project;
|
||||
collab: ProjectCollaboration | null;
|
||||
onSave: (data: ProjectCollaboration | null) => void;
|
||||
onDelete?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -31,7 +35,9 @@
|
||||
"border-secondary text-content items-center rounded-lg border bg-white p-4 text-base font-normal placeholder:opacity-48 focus:ring-0";
|
||||
|
||||
function handleSaveOrCreate() {
|
||||
onSave({ title, description });
|
||||
const projectIri = apiProjectsGetCollectionUrl + "/" + (project.slug ?? project.id);
|
||||
|
||||
onSave({ project: projectIri, title, description, isFulfilled: false });
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<script lang="ts">
|
||||
import RoundSelector from "./RoundSelector.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { wizardState, updateConfiguration, navigateToStep } from "../../../stores/wizard-state";
|
||||
import {
|
||||
currentDraft,
|
||||
navigateToStep,
|
||||
updateConfiguration,
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import Button from "../../library/Button.svelte";
|
||||
|
||||
interface ConfigurationStepProps {
|
||||
@@ -20,6 +24,10 @@
|
||||
|
||||
let { onContinue }: ConfigurationStepProps = $props();
|
||||
|
||||
let projectDeadline = $derived(
|
||||
$currentDraft?.wizardForm.configuration.projectDeadline ?? "minimum",
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle Continue button
|
||||
* Simple navigation to next step (2) - validation happens on save/submit
|
||||
@@ -60,10 +68,7 @@
|
||||
{$t("pages.project.edit.configuration.rounds.description")}
|
||||
</p>
|
||||
</div>
|
||||
<RoundSelector
|
||||
bind:deadline={$wizardState.configuration.projectDeadline}
|
||||
onChange={handleRoundsChange}
|
||||
/>
|
||||
<RoundSelector bind:deadline={projectDeadline} onChange={handleRoundsChange} />
|
||||
</div>
|
||||
|
||||
<!-- Continue Button -->
|
||||
|
||||
@@ -6,35 +6,29 @@
|
||||
import MoreAndLess from "../../icons/MoreAndLess.svelte";
|
||||
import Button from "../../library/Button.svelte";
|
||||
|
||||
import type {
|
||||
ProjectBudgetItem,
|
||||
ProjectCollaboration,
|
||||
ProjectReward,
|
||||
} from "../../../openapi/client";
|
||||
import type { Project } from "../../../openapi/client";
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
onclick,
|
||||
variant,
|
||||
open = $bindable(false),
|
||||
showToast = $bindable(false),
|
||||
reward,
|
||||
collab,
|
||||
budgetItem,
|
||||
onSave,
|
||||
} = $props<{
|
||||
interface Props {
|
||||
project: Project;
|
||||
title: string;
|
||||
description: string;
|
||||
onclick: () => void;
|
||||
variant: "reward" | "collab" | "budget";
|
||||
open: boolean;
|
||||
showToast: boolean;
|
||||
reward?: ProjectReward | null;
|
||||
collab?: ProjectCollaboration | null;
|
||||
budgetItem?: ProjectBudgetItem | null;
|
||||
onSave: (data: any) => void;
|
||||
}>();
|
||||
}
|
||||
|
||||
let {
|
||||
project,
|
||||
title,
|
||||
description,
|
||||
onclick,
|
||||
variant,
|
||||
open = $bindable(false),
|
||||
showToast = $bindable(false),
|
||||
onSave,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -42,7 +36,7 @@
|
||||
>
|
||||
<div class="flex flex-col gap-4 text-ellipsis">
|
||||
<h2
|
||||
class="text-soft-purple font-bold {variant === 'budget'
|
||||
class="text-purple-soft font-bold {variant === 'budget'
|
||||
? 'text-2xl leading-8'
|
||||
: 'text-4xl leading-12'}"
|
||||
>
|
||||
@@ -51,26 +45,22 @@
|
||||
<p class="text-variant1 text-base font-normal">
|
||||
{description}
|
||||
</p>
|
||||
<Button
|
||||
kind="secondary"
|
||||
class="mt-auto flex w-full items-center justify-center gap-2"
|
||||
{onclick}
|
||||
>
|
||||
<MoreAndLess sign="more" class="p-0.5625" />
|
||||
{#if variant === "reward"}
|
||||
{$t("pages.project.edit.rewards.add.button")}
|
||||
{:else if variant === "collab"}
|
||||
{$t("pages.project.edit.collaborations.add.button")}
|
||||
{:else if variant === "budget"}
|
||||
{$t("pages.project.edit.budget.add.button")}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<Button kind="secondary" class="flex w-full items-center justify-center gap-2" {onclick}>
|
||||
<MoreAndLess sign="more" class="p-0.5625" />
|
||||
{#if variant === "reward"}
|
||||
{$t("pages.project.edit.rewards.add.button")}
|
||||
{:else if variant === "collab"}
|
||||
{$t("pages.project.edit.collaborations.add.button")}
|
||||
{:else if variant === "budget"}
|
||||
{$t("pages.project.edit.budget.add.button")}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{#if variant === "reward"}
|
||||
<RewardsModal bind:open bind:showToast {onSave} {reward} />
|
||||
<RewardsModal bind:open bind:showToast {onSave} reward={null} {project} />
|
||||
{:else if variant === "collab"}
|
||||
<CollabsModal bind:open bind:showToast {onSave} {collab} />
|
||||
<CollabsModal bind:open bind:showToast {onSave} collab={null} {project} />
|
||||
{:else if variant === "budget"}
|
||||
<BudgetModal bind:open bind:showToast {onSave} {budgetItem} />
|
||||
<BudgetModal bind:open bind:showToast {onSave} budgetItem={null} />
|
||||
{/if}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<script lang="ts">
|
||||
import { t } from "../../../i18n/store";
|
||||
import {
|
||||
validationErrors,
|
||||
touchedFields,
|
||||
markFieldAsTouched,
|
||||
} from "../../../stores/wizard-state";
|
||||
validationErrors,
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import Select from "../../library/Select.svelte";
|
||||
import TextInput from "../../library/TextInput.svelte";
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
<script lang="ts">
|
||||
import { t } from "../../../i18n/store";
|
||||
import {
|
||||
validationErrors,
|
||||
touchedFields,
|
||||
markFieldAsTouched,
|
||||
} from "../../../stores/wizard-state";
|
||||
validationErrors,
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import { getLanguageDisplayName } from "../../../utils/lang";
|
||||
import Select from "../../library/Select.svelte";
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
import Loader from "../../../svgs/Loader.svelte";
|
||||
import UploadIcon from "../../../svgs/UploadIcon.svelte";
|
||||
|
||||
import type { MediaImage } from "../../../stores/wizard-state";
|
||||
import type { MediaImage } from "../../../stores/drafts/projectDraft";
|
||||
|
||||
interface MediaUploaderProps {
|
||||
images: MediaImage[];
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Facebook from "../../../components/icons/social/Facebook.svelte";
|
||||
import Gmail from "../../../components/icons/social/Gmail.svelte";
|
||||
import Instagram from "../../../components/icons/social/Instagram.svelte";
|
||||
import Linkedin from "../../../components/icons/social/Linkedin.svelte";
|
||||
import X from "../../../components/icons/social/X.svelte";
|
||||
// import Facebook from "../../../components/icons/social/Facebook.svelte";
|
||||
// import Gmail from "../../../components/icons/social/Gmail.svelte";
|
||||
// import Instagram from "../../../components/icons/social/Instagram.svelte";
|
||||
// import Linkedin from "../../../components/icons/social/Linkedin.svelte";
|
||||
// import X from "../../../components/icons/social/X.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { isReadyToPublish } from "../../../stores/wizard-state";
|
||||
import Web from "../../icons/social/Web.svelte";
|
||||
import FileUpload from "../../FileUpload.svelte";
|
||||
import Close from "../../icons/Close.svelte";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import RadioButton from "../../library/RadioButton.svelte";
|
||||
import Select from "../../library/Select.svelte";
|
||||
import TextArea from "../../library/TextArea.svelte";
|
||||
// import Select from "../../library/Select.svelte";
|
||||
// import TextArea from "../../library/TextArea.svelte";
|
||||
import TextInput from "../../library/TextInput.svelte";
|
||||
import Toggle from "../../library/ToggleSwitch.svelte";
|
||||
|
||||
@@ -18,32 +18,81 @@
|
||||
|
||||
let { project: _project, onPublish }: { project: Project; onPublish?: () => void } = $props();
|
||||
|
||||
type ContactEntry = { type: "email" | "phone"; value: string; preferred: boolean };
|
||||
type ContactEntry = { type: "email" | "phone" | "text"; value: string; preferred: boolean };
|
||||
|
||||
let legalEntityType = $state<"individual" | "organization">("individual");
|
||||
let prefill = $state("");
|
||||
// let prefill = $state("");
|
||||
let name = $state("");
|
||||
let taxId = $state("");
|
||||
let territory = $state("");
|
||||
let country = $state("");
|
||||
let teamDescription = $state("");
|
||||
// let territory = $state("");
|
||||
// let country = $state("");
|
||||
// let teamDescription = $state("");
|
||||
let privateContacts = $state<ContactEntry[]>([
|
||||
{ type: "email", value: "", preferred: false },
|
||||
{ type: "email", value: "", preferred: true },
|
||||
{ type: "phone", value: "", preferred: false },
|
||||
]);
|
||||
let preferredIndex = $state(0);
|
||||
let publicLinks = $state({
|
||||
website: "",
|
||||
email: "",
|
||||
instagram: "",
|
||||
facebook: "",
|
||||
twitter: "",
|
||||
linkedin: "",
|
||||
});
|
||||
let iban = $state("");
|
||||
let bankCertificateFiles = $state<File[]>([]);
|
||||
// let publicLinks = $state({
|
||||
// website: "",
|
||||
// email: "",
|
||||
// instagram: "",
|
||||
// facebook: "",
|
||||
// twitter: "",
|
||||
// linkedin: "",
|
||||
// });
|
||||
|
||||
let touched = $state<Set<string>>(new Set());
|
||||
|
||||
function touch(field: string) {
|
||||
touched = new Set([...touched, field]);
|
||||
}
|
||||
|
||||
function setPreferred(i: number) {
|
||||
preferredIndex = i;
|
||||
privateContacts = privateContacts.map((c, idx) => ({ ...c, preferred: idx === i }));
|
||||
}
|
||||
|
||||
function addContact() {
|
||||
privateContacts = [...privateContacts, { type: "text", value: "", preferred: false }];
|
||||
}
|
||||
|
||||
function deleteContact(i: number) {
|
||||
if (privateContacts[i]?.type === "email") return;
|
||||
const wasPreferred = i === preferredIndex;
|
||||
privateContacts = privateContacts.filter((_, idx) => idx !== i);
|
||||
if (wasPreferred) {
|
||||
preferredIndex = 0;
|
||||
privateContacts = privateContacts.map((c, idx) => ({ ...c, preferred: idx === 0 }));
|
||||
} else if (i < preferredIndex) {
|
||||
preferredIndex = preferredIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function ibanValid(value: string): boolean {
|
||||
const normalized = value.replace(/\s/g, "").toUpperCase();
|
||||
return /^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/.test(normalized);
|
||||
}
|
||||
|
||||
let errors = $derived({
|
||||
name: !name.trim() ? $t("system.validation.missingRequiredFields") : undefined,
|
||||
taxId: !taxId.trim() ? $t("system.validation.missingRequiredFields") : undefined,
|
||||
contacts: !privateContacts[preferredIndex]?.value.trim()
|
||||
? $t("system.validation.missingRequiredFields")
|
||||
: undefined,
|
||||
iban: !iban.trim()
|
||||
? $t("system.validation.missingRequiredFields")
|
||||
: !ibanValid(iban)
|
||||
? $t("pages.project.edit.aboutYou.paymentData.ibanInvalid")
|
||||
: undefined,
|
||||
bankCertificate:
|
||||
bankCertificateFiles.length === 0
|
||||
? $t("system.validation.missingRequiredFields")
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let isValid = $derived(Object.values(errors).every((e) => e === undefined));
|
||||
</script>
|
||||
|
||||
<div class="w-1/2">
|
||||
@@ -56,12 +105,13 @@
|
||||
<p class="text-content text-base">{$t("pages.project.edit.aboutYou.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<!-- Pre-fill from profile -->
|
||||
<!-- Pre-fill from profile
|
||||
<div class="flex flex-col gap-2">
|
||||
<Select bind:value={prefill} name="prefill">
|
||||
<option value="">{$t("pages.project.edit.aboutYou.prefill")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Forma jurídica -->
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -89,11 +139,14 @@
|
||||
{$t("pages.project.edit.aboutYou.name")}
|
||||
</h2>
|
||||
<p class="text-content text-base">{$t("pages.project.edit.aboutYou.nameHelper")}</p>
|
||||
<TextInput
|
||||
bind:value={name}
|
||||
placeholder={$t("pages.project.edit.aboutYou.namePlaceholder")}
|
||||
name="name"
|
||||
/>
|
||||
<div onfocusout={() => touch("name")}>
|
||||
<TextInput
|
||||
bind:value={name}
|
||||
placeholder={$t("pages.project.edit.aboutYou.namePlaceholder")}
|
||||
name="name"
|
||||
error={touched.has("name") ? errors.name : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NIF del impulsor -->
|
||||
@@ -102,14 +155,17 @@
|
||||
{$t("pages.project.edit.aboutYou.taxId")}
|
||||
</h2>
|
||||
<p class="text-content text-base">{$t("pages.project.edit.aboutYou.taxIdHelper")}</p>
|
||||
<TextInput
|
||||
bind:value={taxId}
|
||||
placeholder={$t("pages.project.edit.aboutYou.taxIdPlaceholder")}
|
||||
name="taxId"
|
||||
/>
|
||||
<div onfocusout={() => touch("taxId")}>
|
||||
<TextInput
|
||||
bind:value={taxId}
|
||||
placeholder={$t("pages.project.edit.aboutYou.taxIdPlaceholder")}
|
||||
name="taxId"
|
||||
error={touched.has("taxId") ? errors.taxId : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lugar de actividad -->
|
||||
<!-- Lugar de actividad
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-2xl font-bold text-black">
|
||||
{$t("pages.project.edit.aboutYou.location")}
|
||||
@@ -130,8 +186,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Descripción del equipo impulsor -->
|
||||
<!-- Descripción del equipo impulsor
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-2xl font-bold text-black">
|
||||
{$t("pages.project.edit.aboutYou.teamDescription")}
|
||||
@@ -146,6 +203,7 @@
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Datos de contacto privados -->
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -160,14 +218,23 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each privateContacts as contact, i}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex-1" onfocusout={() => touch(`contact-${i}`)}>
|
||||
<TextInput
|
||||
bind:value={contact.value}
|
||||
type={contact.type === "email" ? "email" : "tel"}
|
||||
type={contact.type === "email"
|
||||
? "email"
|
||||
: contact.type === "phone"
|
||||
? "tel"
|
||||
: "text"}
|
||||
placeholder={contact.type === "email"
|
||||
? "*" + $t("pages.project.edit.aboutYou.email")
|
||||
: $t("pages.project.edit.aboutYou.phone")}
|
||||
? $t("pages.project.edit.aboutYou.email")
|
||||
: contact.type === "phone"
|
||||
? $t("pages.project.edit.aboutYou.phone")
|
||||
: $t("common.textPlaceholder")}
|
||||
name={`contact-${i}`}
|
||||
error={contact.preferred && touched.has(`contact-${i}`)
|
||||
? errors.contacts
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
<RadioButton
|
||||
@@ -176,12 +243,29 @@
|
||||
label={$t("pages.project.edit.aboutYou.preferred")}
|
||||
onchange={() => setPreferred(i)}
|
||||
/>
|
||||
{#if contact.type !== "email"}
|
||||
<button
|
||||
type="button"
|
||||
class="text-tertiary shrink-0 hover:opacity-75"
|
||||
onclick={() => deleteContact(i)}
|
||||
aria-label={$t("common.delete")}
|
||||
>
|
||||
<Close class="size-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="text-secondary w-fit text-sm font-medium underline"
|
||||
onclick={addContact}
|
||||
>
|
||||
+ {$t("pages.project.edit.aboutYou.addContact")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Datos de contacto públicos -->
|
||||
<!-- Datos de contacto públicos
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-2xl font-bold text-black">
|
||||
@@ -256,6 +340,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Datos de pago -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-2xl font-bold text-black">
|
||||
{$t("pages.project.edit.aboutYou.paymentData.title")}
|
||||
</h2>
|
||||
<p class="text-content text-base">
|
||||
{$t("pages.project.edit.aboutYou.paymentData.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div onfocusout={() => touch("iban")}>
|
||||
<TextInput
|
||||
bind:value={iban}
|
||||
labelText={$t("pages.project.edit.aboutYou.paymentData.iban")}
|
||||
helperText={$t("pages.project.edit.aboutYou.paymentData.ibanHelper")}
|
||||
placeholder={$t("pages.project.edit.aboutYou.paymentData.ibanPlaceholder")}
|
||||
name="iban"
|
||||
error={touched.has("iban") ? errors.iban : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base font-bold text-black">
|
||||
{$t("pages.project.edit.aboutYou.paymentData.bankCertificate")}
|
||||
</p>
|
||||
<p class="text-content text-base">
|
||||
{$t("pages.project.edit.aboutYou.paymentData.bankCertificateHelper")}
|
||||
</p>
|
||||
<FileUpload
|
||||
bind:files={bankCertificateFiles}
|
||||
accept={["application/pdf", "image/jpeg", "image/png"]}
|
||||
maxSizeMB={10}
|
||||
ariaLabel={$t(
|
||||
"pages.project.edit.aboutYou.paymentData.bankCertificateAriaLabel",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex">
|
||||
<Button
|
||||
@@ -263,7 +388,7 @@
|
||||
size="md"
|
||||
class="min-w-50 disabled:pointer-events-none disabled:opacity-24"
|
||||
onclick={onPublish}
|
||||
disabled={$isReadyToPublish ? false : true}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{$t("pages.project.edit.aboutYou.continue")}
|
||||
</Button>
|
||||
|
||||
@@ -7,20 +7,23 @@
|
||||
deleteReward,
|
||||
updateReward,
|
||||
validationErrors,
|
||||
type WizardReward,
|
||||
} from "../../../stores/wizard-state";
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import UnitIcon from "../../../svgs/UnitIcon.svelte";
|
||||
import { formatCurrency } from "../../../utils/currencies";
|
||||
import { renderMarkdown } from "../../../utils/renderMarkdown";
|
||||
import Button from "../../library/Button.svelte";
|
||||
|
||||
import type { Project, ProjectReward } from "../../../openapi/client";
|
||||
|
||||
let {
|
||||
project,
|
||||
reward,
|
||||
index,
|
||||
loading = $bindable(false),
|
||||
isCreateCard = false,
|
||||
}: {
|
||||
reward: WizardReward | null;
|
||||
project: Project;
|
||||
reward: ProjectReward | null;
|
||||
index?: number;
|
||||
loading: boolean;
|
||||
isCreateCard?: boolean;
|
||||
@@ -29,7 +32,7 @@
|
||||
let openModal = $state(false);
|
||||
let showModalErrorToast = $state(false);
|
||||
|
||||
function handleSaveReward(data: WizardReward | null) {
|
||||
function handleSaveReward(data: ProjectReward | null) {
|
||||
if (!data) return;
|
||||
let errors;
|
||||
|
||||
@@ -64,6 +67,7 @@
|
||||
|
||||
{#if isCreateCard}
|
||||
<CreateCard
|
||||
{project}
|
||||
title={$t("pages.project.edit.rewards.add.title")}
|
||||
description={$t("pages.project.edit.rewards.add.description")}
|
||||
variant="reward"
|
||||
@@ -126,6 +130,7 @@
|
||||
<RewardsModal
|
||||
bind:open={openModal}
|
||||
bind:showToast={showModalErrorToast}
|
||||
{project}
|
||||
{reward}
|
||||
onSave={handleSaveReward}
|
||||
onDelete={handleDeleteReward}
|
||||
|
||||
@@ -4,25 +4,29 @@
|
||||
import DeleteModal from "./DeleteModal.svelte";
|
||||
import RewardItemsSelector from "./RewardItemsSelector.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { validationErrors, type WizardReward } from "../../../stores/wizard-state";
|
||||
import { apiProjectsGetCollectionUrl } from "../../../openapi/client/paths.gen";
|
||||
import { validationErrors } from "../../../stores/drafts/projectDraft";
|
||||
import { defaultCurrency } from "../../../utils/currencies";
|
||||
import FileUpload from "../../FileUpload.svelte";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Toast from "../../library/Toast.svelte";
|
||||
|
||||
import type { Project, ProjectReward } from "../../../openapi/client";
|
||||
import type { ClassNameValue } from "tailwind-merge";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
showToast = $bindable(false),
|
||||
project,
|
||||
reward,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
showToast: boolean;
|
||||
reward: WizardReward | null;
|
||||
onSave: (data: WizardReward | null) => void;
|
||||
project: Project;
|
||||
reward: ProjectReward | null;
|
||||
onSave: (data: ProjectReward | null) => void;
|
||||
onDelete?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -40,7 +44,10 @@
|
||||
"border-secondary text-content items-center rounded-lg border bg-white p-4 text-base font-normal placeholder:opacity-48 focus:ring-0";
|
||||
|
||||
function handleSaveOrCreate() {
|
||||
const projectIri = apiProjectsGetCollectionUrl + "/" + (project.slug ?? project.id);
|
||||
|
||||
onSave({
|
||||
project: projectIri,
|
||||
title,
|
||||
description,
|
||||
money: {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import RewardsCard from "./RewardsCard.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import { navigateToStep, wizardState, type WizardReward } from "../../../stores/wizard-state";
|
||||
import { currentDraft, navigateToStep } from "../../../stores/drafts/projectDraft";
|
||||
import Button from "../../library/Button.svelte";
|
||||
import Grid from "../../library/Grid.svelte";
|
||||
import LoadingSpinner from "../../search/LoadingSpinner.svelte";
|
||||
|
||||
let rewards = $state<WizardReward[]>($wizardState.rewards);
|
||||
import type { Project, ProjectReward } from "../../../openapi/client";
|
||||
|
||||
let { project }: { project: Project } = $props();
|
||||
|
||||
let rewards = $state<ProjectReward[]>($currentDraft?.wizardForm.rewards || []);
|
||||
let loading = $state(false);
|
||||
|
||||
/**
|
||||
@@ -19,12 +23,12 @@
|
||||
|
||||
async function loadRewards() {
|
||||
loading = true;
|
||||
rewards = $wizardState.rewards;
|
||||
rewards = $currentDraft?.wizardForm.rewards || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($wizardState) loadRewards();
|
||||
if ($currentDraft) loadRewards();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -42,10 +46,10 @@
|
||||
{:else}
|
||||
<Grid>
|
||||
{#each rewards as reward, index}
|
||||
<RewardsCard {index} {reward} bind:loading />
|
||||
<RewardsCard {project} {index} {reward} bind:loading />
|
||||
{/each}
|
||||
|
||||
<RewardsCard isCreateCard={true} reward={null} bind:loading />
|
||||
<RewardsCard isCreateCard={true} {project} reward={null} bind:loading />
|
||||
</Grid>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,54 +1,3 @@
|
||||
<!--
|
||||
Video URL Input Component
|
||||
|
||||
Accessible video URL input component with YouTube and Vimeo validation and embed preview.
|
||||
|
||||
Features:
|
||||
- URL validation for YouTube and Vimeo
|
||||
- Live embed preview in iframe
|
||||
- Remove functionality with confirmation
|
||||
- Inline error messages with visual feedback
|
||||
- Loading states during validation
|
||||
- Full keyboard accessibility
|
||||
|
||||
Design System Compliance:
|
||||
- Colors: bg-purple-tint for button, border-secondary for input
|
||||
- Border radius: rounded-3xl (24px) for button, rounded-lg (8px) for input/preview
|
||||
- Spacing: gap-2 for vertical layout
|
||||
- Typography: text-sm for errors
|
||||
- Icons: Uses VideoIcon and CloseIcon from /src/svgs/
|
||||
- Components: Uses Button (kind="secondary", kind="ghost") and TextInput from library
|
||||
- Uses tailwind-merge for class composition
|
||||
|
||||
Props:
|
||||
- video: VideoEmbed | null - Current video embed data (url, type, embedId)
|
||||
- onChange: (video: VideoEmbed | null) => void - Callback when video changes
|
||||
- class?: ClassNameValue - Additional Tailwind classes
|
||||
|
||||
Usage:
|
||||
```svelte
|
||||
<VideoUrlInput
|
||||
bind:video={campaignVideo}
|
||||
onChange={(video) => updateVideo(video)}
|
||||
class="mt-4"
|
||||
/>
|
||||
```
|
||||
|
||||
Accessibility:
|
||||
- ARIA labels for all interactive elements
|
||||
- aria-invalid for validation errors
|
||||
- role="alert" for error messages
|
||||
- Keyboard navigation support (focusable buttons)
|
||||
- Proper label associations with input
|
||||
- Screen reader friendly messages
|
||||
|
||||
Validation Strategy:
|
||||
- YouTube: Supports youtube.com/watch?v= and youtu.be/ formats
|
||||
- Vimeo: Supports vimeo.com/{id} format
|
||||
- Real-time validation on input change
|
||||
- Clear error messages for invalid URLs
|
||||
- Empty input clears validation errors
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { twMerge, type ClassNameValue } from "tailwind-merge";
|
||||
|
||||
@@ -58,103 +7,45 @@
|
||||
import { t } from "../../../i18n/store";
|
||||
import VideoIcon from "../../../svgs/VideoIcon.svelte";
|
||||
|
||||
import type { VideoEmbed } from "../../../stores/wizard-state";
|
||||
|
||||
interface VideoUrlInputProps {
|
||||
video: VideoEmbed | null;
|
||||
onChange: (video: VideoEmbed | null) => void;
|
||||
video?: string;
|
||||
onChange: (video: string | null) => void;
|
||||
class?: ClassNameValue;
|
||||
}
|
||||
|
||||
let { video, onChange, class: className = "" }: VideoUrlInputProps = $props();
|
||||
|
||||
let videoUrl = $state(video?.url || "");
|
||||
let videoUrl = $state(video || "");
|
||||
let validationError = $state("");
|
||||
let showInput = $state(false);
|
||||
let showInput = $state(!!video);
|
||||
|
||||
// Watch for videoUrl changes and trigger validation
|
||||
$effect(() => {
|
||||
if (showInput && videoUrl !== (video?.url || "")) {
|
||||
handleUrlChange(videoUrl);
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extracts embed information from YouTube or Vimeo URLs
|
||||
*
|
||||
* @param url - The video URL to parse
|
||||
* @returns VideoEmbed object or null if invalid
|
||||
*/
|
||||
function extractEmbedInfo(url: string): VideoEmbed | null {
|
||||
if (!url || url.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// YouTube patterns
|
||||
const youtubeRegex = /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
const youtubeMatch = url.match(youtubeRegex);
|
||||
if (youtubeMatch) {
|
||||
return {
|
||||
type: "youtube",
|
||||
embedId: youtubeMatch[1],
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
|
||||
// Vimeo patterns
|
||||
const vimeoRegex = /vimeo\.com\/(\d+)/;
|
||||
const vimeoMatch = url.match(vimeoRegex);
|
||||
if (vimeoMatch) {
|
||||
return {
|
||||
type: "vimeo",
|
||||
embedId: vimeoMatch[1],
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates embed URL for iframe based on video type
|
||||
*
|
||||
* @param embedInfo - VideoEmbed object with type and embedId
|
||||
* @returns Embed URL string for iframe src
|
||||
*/
|
||||
function getEmbedUrl(embedInfo: VideoEmbed): string {
|
||||
if (embedInfo.type === "youtube") {
|
||||
return `https://www.youtube.com/embed/${embedInfo.embedId}`;
|
||||
} else if (embedInfo.type === "vimeo") {
|
||||
return `https://player.vimeo.com/video/${embedInfo.embedId}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles video URL input changes with real-time validation
|
||||
* Validates URL format and calls onChange callback
|
||||
*/
|
||||
function handleUrlChange(url: string) {
|
||||
if (url.trim().length === 0) {
|
||||
videoUrl = url;
|
||||
|
||||
if (!url.trim()) {
|
||||
validationError = "";
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const embedInfo = extractEmbedInfo(url);
|
||||
if (embedInfo) {
|
||||
validationError = "";
|
||||
onChange(embedInfo);
|
||||
} else {
|
||||
if (!isValidUrl(url)) {
|
||||
validationError = $t("wizard.validation.campaign_info.video.invalid_url");
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
validationError = "";
|
||||
onChange(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles video removal
|
||||
* Clears input, validation errors, and calls onChange callback
|
||||
*/
|
||||
function handleRemove() {
|
||||
videoUrl = "";
|
||||
validationError = "";
|
||||
@@ -162,22 +53,18 @@
|
||||
onChange(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows input field and focuses it
|
||||
*/
|
||||
function handleShowInput() {
|
||||
showInput = true;
|
||||
setTimeout(() => document.getElementById("video-url-input")?.focus(), 0);
|
||||
}
|
||||
|
||||
// Compute embed preview from current video
|
||||
const embedPreview = $derived(video ? extractEmbedInfo(video.url) : null);
|
||||
setTimeout(() => {
|
||||
document.getElementById("video-url-input")?.focus();
|
||||
}, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={twMerge("flex flex-col gap-2", className)}>
|
||||
{#if !video}
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Add Video Button -->
|
||||
<Button
|
||||
type="button"
|
||||
kind="secondary"
|
||||
@@ -191,7 +78,6 @@
|
||||
{/snippet}
|
||||
</Button>
|
||||
|
||||
<!-- Video URL Input -->
|
||||
{#if showInput}
|
||||
<TextInput
|
||||
id="video-url-input"
|
||||
@@ -199,24 +85,16 @@
|
||||
placeholder={$t("pages.project.edit.campaignInfo.media.videoPlaceholder")}
|
||||
bind:value={videoUrl}
|
||||
error={validationError}
|
||||
onInput={(e) => handleUrlChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if embedPreview}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Video Embed Preview -->
|
||||
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<iframe
|
||||
src={getEmbedUrl(embedPreview)}
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
title="Video preview"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
></iframe>
|
||||
<div class="border-secondary bg-light-surface rounded-lg border p-3 text-sm break-all">
|
||||
{video}
|
||||
</div>
|
||||
|
||||
<!-- Remove Video Button -->
|
||||
<Button
|
||||
type="button"
|
||||
kind="secondary"
|
||||
@@ -229,6 +107,7 @@
|
||||
<span class="h-4 w-4">
|
||||
<CloseIcon />
|
||||
</span>
|
||||
|
||||
{$t("pages.project.edit.campaignInfo.media.removeVideo")}
|
||||
{/snippet}
|
||||
</Button>
|
||||
|
||||
@@ -26,7 +26,7 @@ Supports: primary (Aplicar filtros), secondary (Buscar), ghost (Cerrar filtros)
|
||||
|
||||
// Base button classes - matching Figma design exactly
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center font-body font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed gap-2";
|
||||
"inline-flex items-center justify-center font-body font-bold transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed gap-2";
|
||||
|
||||
// Size classes - matching Figma specifications exactly
|
||||
const sizeClasses = {
|
||||
|
||||
@@ -18,9 +18,9 @@ Integrated with searchStore for state management and URL synchronization
|
||||
interface Props {
|
||||
locale?: string;
|
||||
initialFilters?: {
|
||||
query?: string;
|
||||
statusFilter?: string;
|
||||
categories?: string[];
|
||||
title?: string;
|
||||
status?: string;
|
||||
"categories[]"?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ Integrated with searchStore for state management and URL synchronization
|
||||
// Initialize with server-provided filters if available (without triggering search)
|
||||
if (initialFilters) {
|
||||
searchStore.initializeFilters({
|
||||
query: initialFilters.query || "",
|
||||
statusFilter: initialFilters.statusFilter || "",
|
||||
categories: initialFilters.categories || [],
|
||||
title: initialFilters.title || "",
|
||||
status: initialFilters.status || "",
|
||||
"categories[]": initialFilters["categories[]"] || [],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -72,19 +72,19 @@ Integrated with searchStore for state management and URL synchronization
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="border-grey mx-auto flex w-80 flex-col gap-6 rounded-3xl border bg-white px-4 py-4 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)] min-[500px]:mx-0 min-[500px]:w-auto lg:gap-10 lg:rounded-4xl lg:px-8 lg:py-6"
|
||||
class="border-grey mx-auto flex w-80 flex-col gap-4 rounded-3xl border bg-white px-4 py-4 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)] min-[500px]:mx-auto min-[500px]:w-auto min-[500px]:max-w-6xl lg:gap-6 lg:rounded-4xl lg:px-6 lg:py-5"
|
||||
data-testid="search-filters"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-3 min-[500px]:flex-row min-[500px]:items-center min-[500px]:gap-16"
|
||||
class="flex flex-col gap-3 min-[500px]:flex-row min-[500px]:items-center min-[500px]:gap-6"
|
||||
>
|
||||
<!-- Search section with input and button -->
|
||||
<div class="flex flex-1 flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<!-- Search input -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<SearchInput
|
||||
value={$searchFilters.query}
|
||||
onSearch={(query) => updateFilters({ query })}
|
||||
value={$searchFilters.title}
|
||||
onSearch={(title) => updateFilters({ title })}
|
||||
onEnter={handleSearch}
|
||||
onClear={handleSearch}
|
||||
placeholder={$t("pages.search.input.placeholder")}
|
||||
@@ -104,18 +104,16 @@ Integrated with searchStore for state management and URL synchronization
|
||||
</div>
|
||||
|
||||
<!-- Filter toggle button - full width on mobile, auto on desktop -->
|
||||
{#if !filtersOpen}
|
||||
<SearchButton
|
||||
variant="ghost"
|
||||
onclick={toggleFilters}
|
||||
data-testid="toggle-filters"
|
||||
aria-expanded={filtersOpen}
|
||||
class="w-full justify-center min-[500px]:w-auto"
|
||||
>
|
||||
<FilterIcon width="16" height="16" class="mr-2" />
|
||||
{$t("pages.search.filters.show")}
|
||||
</SearchButton>
|
||||
{/if}
|
||||
<SearchButton
|
||||
variant="ghost"
|
||||
onclick={toggleFilters}
|
||||
data-testid="toggle-filters"
|
||||
aria-expanded={filtersOpen}
|
||||
class="w-full justify-center min-[500px]:w-auto"
|
||||
>
|
||||
<FilterIcon width="16" height="16" class="mr-2" />
|
||||
{filtersOpen ? $t("pages.search.filters.close") : $t("pages.search.filters.show")}
|
||||
</SearchButton>
|
||||
</div>
|
||||
|
||||
<!-- Expanded filters section (collapsed by default) -->
|
||||
@@ -125,8 +123,8 @@ Integrated with searchStore for state management and URL synchronization
|
||||
<FilterDropdown
|
||||
options={statusOptions}
|
||||
placeholder={$t("pages.search.filters.status.label")}
|
||||
selectedValue={$searchFilters.statusFilter}
|
||||
onSelect={(value) => updateFilters({ statusFilter: value })}
|
||||
selectedValue={$searchFilters.status}
|
||||
onSelect={(value) => updateFilters({ status: value })}
|
||||
data-testid="status-filter"
|
||||
/>
|
||||
</div>
|
||||
@@ -134,8 +132,8 @@ Integrated with searchStore for state management and URL synchronization
|
||||
<!-- Category filters -->
|
||||
<div class="w-full">
|
||||
<CategoryFilter
|
||||
selectedCategories={$searchFilters.categories}
|
||||
onCategoryChange={(categories) => updateFilters({ categories })}
|
||||
selectedCategories={$searchFilters["categories[]"] || []}
|
||||
onCategoryChange={(categories) => updateFilters({ "categories[]": categories })}
|
||||
data-testid="category-filter"
|
||||
/>
|
||||
</div>
|
||||
@@ -153,17 +151,6 @@ Integrated with searchStore for state management and URL synchronization
|
||||
>
|
||||
{$t("pages.search.filters.apply")}
|
||||
</SearchButton>
|
||||
|
||||
<!-- Close filters button -->
|
||||
<SearchButton
|
||||
variant="ghost"
|
||||
onclick={toggleFilters}
|
||||
data-testid="close-filters-btn"
|
||||
class="w-full min-[500px]:w-auto"
|
||||
>
|
||||
<FilterIcon width="16" height="16" class="mr-2" />
|
||||
{$t("pages.search.filters.close")}
|
||||
</SearchButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
<!--
|
||||
Search Input Component
|
||||
Implements main search bar with floating label matching Figma design exactly
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { t } from "../../i18n/store";
|
||||
import Search from "../library/Search.svelte";
|
||||
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
|
||||
interface Props extends HTMLInputAttributes {
|
||||
interface Props {
|
||||
value?: string;
|
||||
class?: string;
|
||||
"data-testid"?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
onEnter?: () => void;
|
||||
onClear?: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = "",
|
||||
class: className = "",
|
||||
"data-testid": dataTestId,
|
||||
onSearch,
|
||||
onEnter,
|
||||
onClear,
|
||||
placeholder = "",
|
||||
class: className = "",
|
||||
"data-testid": dataTestId,
|
||||
...props
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state(value);
|
||||
|
||||
// Keep local state in sync with prop changes (important for SSR hydration)
|
||||
$effect(() => {
|
||||
searchQuery = value;
|
||||
});
|
||||
@@ -35,7 +31,6 @@ Implements main search bar with floating label matching Figma design exactly
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
searchQuery = target.value;
|
||||
// Update filter state but don't trigger search
|
||||
onSearch?.(searchQuery);
|
||||
}
|
||||
|
||||
@@ -52,62 +47,14 @@ Implements main search bar with floating label matching Figma design exactly
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Search input with floating label -->
|
||||
<div
|
||||
class="border-secondary relative flex h-14 w-full items-center justify-between rounded-3xl border bg-white p-4 transition-colors focus-within:border-[#855a96] {className}"
|
||||
>
|
||||
<!-- Input field -->
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
class="text-content placeholder:text-content flex-1 border-none bg-transparent text-base outline-none focus:ring-0 focus:outline-none focus-visible:outline-none"
|
||||
placeholder={placeholder || $t("pages.search.input.placeholder")}
|
||||
aria-label={$t("pages.search.input.label")}
|
||||
data-testid={dataTestId}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<!-- Close icon -->
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={clearSearch}
|
||||
class="text-secondary ml-2 flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-[#e6e6e6]"
|
||||
aria-label={$t("pages.search.input.clear")}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M15 5L5 15M5 5l10 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Floating label -->
|
||||
<div class="-top-1.125 pointer-events-none absolute left-3 bg-white px-1">
|
||||
<div class="font-body text-xs leading-4 font-medium text-black">
|
||||
{$t("pages.search.input.label")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Remove default search input styling in WebKit browsers */
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Remove blue outline but keep accessibility */
|
||||
input[type="search"]:focus,
|
||||
input[type="search"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
<Search
|
||||
bind:value={searchQuery}
|
||||
label={$t("pages.search.input.label")}
|
||||
placeholder={placeholder || $t("pages.search.input.placeholder")}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onsubmit={() => onEnter?.() || onSearch?.(searchQuery)}
|
||||
onclear={clearSearch}
|
||||
data-testid={dataTestId}
|
||||
class="transition-colors focus-within:border-[#855a96] {className}"
|
||||
/>
|
||||
|
||||
@@ -32,9 +32,9 @@ Manages real-time filtering of campaigns without page reloads
|
||||
initialProjects: Project[];
|
||||
ariaLiveRegion?: "polite" | "assertive" | "off";
|
||||
initialFilters?: {
|
||||
query?: string;
|
||||
statusFilter?: string;
|
||||
categories?: string[];
|
||||
title?: string;
|
||||
status?: string;
|
||||
"categories[]"?: string[];
|
||||
};
|
||||
hasInitialSearch?: boolean;
|
||||
}
|
||||
@@ -93,10 +93,14 @@ Manages real-time filtering of campaigns without page reloads
|
||||
|
||||
const filters = $searchFilters;
|
||||
|
||||
// Add search parameters to URL
|
||||
if (filters.query) params.set("q", filters.query);
|
||||
if (filters.statusFilter) params.set("status", filters.statusFilter);
|
||||
if (filters.categories.length > 0) params.set("categories", filters.categories.join(","));
|
||||
// Add search parameters to URL using API field names
|
||||
if (filters.title) params.set("title", filters.title);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if ((filters["categories[]"]?.length ?? 0) > 0) {
|
||||
(filters["categories[]"] as string[]).forEach((cat) => {
|
||||
params.append("categories[]", cat);
|
||||
});
|
||||
}
|
||||
|
||||
// Update URL using History API to avoid navigation
|
||||
const newUrl = `${url.pathname}${params.toString() ? "?" + params.toString() : ""}`;
|
||||
@@ -129,9 +133,9 @@ Manages real-time filtering of campaigns without page reloads
|
||||
if (initialFilters && hasInitialSearch) {
|
||||
// Initialize store with server-side filters first
|
||||
searchStore.initializeFilters({
|
||||
query: initialFilters.query || "",
|
||||
statusFilter: initialFilters.statusFilter || "",
|
||||
categories: initialFilters.categories || [],
|
||||
title: initialFilters.title || "",
|
||||
status: initialFilters.status || "",
|
||||
"categories[]": initialFilters["categories[]"] || [],
|
||||
});
|
||||
|
||||
// Trigger API search with the URL parameters
|
||||
|
||||
@@ -3,4 +3,13 @@ export type ControlItem = {
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export const ACL: ControlItem[] = [{ path: "/admin", roles: ["ROLE_ADMIN"] }];
|
||||
export const ACL: ControlItem[] = [
|
||||
{
|
||||
path: "/project/.*/edit",
|
||||
roles: ["ROLE_USER"],
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
roles: ["ROLE_ADMIN"],
|
||||
},
|
||||
];
|
||||
|
||||
+48
-2
@@ -1,6 +1,8 @@
|
||||
import { ACL, type ControlItem } from "./access-control";
|
||||
import { isSupportedLocale } from "../i18n/locales";
|
||||
|
||||
const regexCache = new Map<string, RegExp>();
|
||||
|
||||
function normalizePath(pathname: string): string {
|
||||
const path = pathname.replace(/\/+$/, "");
|
||||
|
||||
@@ -13,8 +15,42 @@ function normalizePath(pathname: string): string {
|
||||
return "/" + segments.join("/");
|
||||
}
|
||||
|
||||
function matchesPath(pathname: string, basePath: string): boolean {
|
||||
return pathname === basePath || pathname.startsWith(basePath + "/");
|
||||
function isValidRegex(pattern: string): boolean {
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRegex(path: string): RegExp {
|
||||
if (!regexCache.has(path)) {
|
||||
regexCache.set(path, new RegExp(`^${path}$`));
|
||||
}
|
||||
|
||||
return regexCache.get(path)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a pathname matches a given path.
|
||||
* @param pathname The pathname to check, e.g. "/admin/dashboard"
|
||||
* @param path The path to match against, which can be an exact path (e.g. "/admin") or a regex pattern (e.g. "/admin/.*")
|
||||
* @returns True if the pathname matches the path, false otherwise
|
||||
*/
|
||||
function matchesPath(pathname: string, path: string): boolean {
|
||||
// Exact match
|
||||
if (pathname === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dynamic regex route
|
||||
if (isValidRegex(path)) {
|
||||
return getRegex(path).test(pathname);
|
||||
}
|
||||
|
||||
// Nested routes
|
||||
return pathname.startsWith(path + "/");
|
||||
}
|
||||
|
||||
export function getMatchingACL(pathname: string): ControlItem | null {
|
||||
@@ -29,6 +65,16 @@ export function getMatchingACL(pathname: string): ControlItem | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is authorized to access a control item.
|
||||
* @param control The control item (ACL) to check authorization for
|
||||
* @param roles The roles of the current user
|
||||
* @returns True if the user is authorized, false otherwise
|
||||
*/
|
||||
export function isAuthorized(control: ControlItem, roles: string[]): boolean {
|
||||
if (control.roles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return control.roles.some((role) => roles.includes(role));
|
||||
}
|
||||
|
||||
@@ -843,6 +843,8 @@
|
||||
"content-footer": {
|
||||
"lastEdited": "Última modificació: {{date}} {{time}}",
|
||||
"downloadDetails": "Descarrega el detall",
|
||||
"downloadCertificates": "Descarrega certificats",
|
||||
"downloadPdf": "Descarrega PDF",
|
||||
"annotations": "{{annotations}} Anotacions"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -843,6 +843,8 @@
|
||||
"content-footer": {
|
||||
"lastEdited": "Last modification: {{date}} {{time}}",
|
||||
"downloadDetails": "Download details",
|
||||
"downloadCertificates": "Download certificates",
|
||||
"downloadPdf": "Download PDF",
|
||||
"annotations": "{{annotations}} annotations"
|
||||
}
|
||||
},
|
||||
|
||||
+211
-195
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"_": "Global UI translations (buttons, labels, etc.)",
|
||||
"_": "Traducciones globales de la interfaz (botones, etiquetas, etc.)",
|
||||
"greeting": "Hola",
|
||||
"farewell": "Adiós",
|
||||
"donate": "Donar",
|
||||
@@ -17,16 +17,22 @@
|
||||
"remove": "Eliminar",
|
||||
"textPlaceholder": "Escribe aquí",
|
||||
"remember": "Recordar",
|
||||
"search": "Buscar",
|
||||
"searchEtc": "Buscar...",
|
||||
"countdown": {
|
||||
"remaining": "Quedan",
|
||||
"expired": "Campaña finalizada"
|
||||
},
|
||||
"login": "Iniciar Sesión",
|
||||
"goHome": "Volver a inicio",
|
||||
"viewAll": "Ver todo"
|
||||
"viewAll": "Ver todo",
|
||||
"noData": "No hay datos disponibles",
|
||||
"tooltip": {
|
||||
"copied": "¡Copiado!"
|
||||
}
|
||||
},
|
||||
"domain": {
|
||||
"_": "Business concepts (project, reward, contribution, etc.)",
|
||||
"_": "Conceptos de negocio (proyecto, recompensa, contribución, etc.)",
|
||||
"header": {
|
||||
"categories": "Proyectos"
|
||||
},
|
||||
@@ -62,6 +68,41 @@
|
||||
"completed": "Completadas"
|
||||
}
|
||||
},
|
||||
"gatewayCheckout": {
|
||||
"paymentMethod": "Método",
|
||||
"payments": {
|
||||
"tpv": "Tarjeta",
|
||||
"paypal": "Paypal",
|
||||
"stripe": "Stripe",
|
||||
"wallet": "Monedero"
|
||||
}
|
||||
},
|
||||
"charges": {
|
||||
"status": {
|
||||
"to_charge": "Por cobrar",
|
||||
"in_charge": "Cobrado",
|
||||
"to_refund": "Por reembolsar",
|
||||
"refunded": "Reembolsado",
|
||||
"to_wallet": "Por reembolsar a monedero",
|
||||
"walleted": "Reembolsado a monedero"
|
||||
},
|
||||
"refund": {
|
||||
"to_wallet": "Sí",
|
||||
"to_gateway": "No"
|
||||
},
|
||||
"entityLabels": {
|
||||
"projects": "Proyectos",
|
||||
"users": "Usuarios",
|
||||
"tipjars": "Botes de propina",
|
||||
"tipjar-id": "ID del bote de propina"
|
||||
},
|
||||
"lastContributions": "Últimos aportes:",
|
||||
"totalizers": {
|
||||
"totalCharges": "Total aportes:",
|
||||
"totalTips": "Total propinas:",
|
||||
"totalFees": "Comisiones totales:"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"risks": {
|
||||
"low": "Riesgo bajo",
|
||||
@@ -71,7 +112,7 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"_": "Page-specific content (/home, /search, /project/[id], etc.)",
|
||||
"_": "Contenido específico de páginas (/home, /search, /project/[id], etc.)",
|
||||
"home": {
|
||||
"banner": {
|
||||
"title": "¡Renovamos Goteo!",
|
||||
@@ -531,6 +572,17 @@
|
||||
"twitter": "Inserta tu cuenta de X/Twitter",
|
||||
"linkedin": "Inserta tu cuenta de LinkedIn"
|
||||
},
|
||||
"paymentData": {
|
||||
"title": "Datos de pago",
|
||||
"subtitle": "Indica los datos bancarios necesarios para gestionar los pagos de la campaña.",
|
||||
"iban": "IBAN",
|
||||
"ibanHelper": "Introduce el IBAN de la cuenta bancaria donde recibirás los fondos recaudados.",
|
||||
"ibanPlaceholder": "ES00 0000 0000 0000 0000 0000",
|
||||
"bankCertificate": "Certificado de titularidad de cuenta",
|
||||
"bankCertificateHelper": "Adjunta el documento que acredita la titularidad de la cuenta bancaria.",
|
||||
"bankCertificateAriaLabel": "Subir certificado de titularidad",
|
||||
"ibanInvalid": "El formato del IBAN no es válido"
|
||||
},
|
||||
"continue": "Siguiente paso"
|
||||
}
|
||||
},
|
||||
@@ -613,7 +665,8 @@
|
||||
"length": "aportes",
|
||||
"title": "Donaciones anónimas",
|
||||
"description": "No todas las personas heróicas llevan capa ni tampoco sienten la necesidad de mostrar su identidad."
|
||||
}
|
||||
},
|
||||
"loadMore": "Ver más"
|
||||
}
|
||||
},
|
||||
"rewards": {
|
||||
@@ -848,7 +901,151 @@
|
||||
},
|
||||
"admin": {
|
||||
"charges": {
|
||||
"title": "Cargos"
|
||||
"title": "Aportes",
|
||||
"description": "Aquí tienes las últimas contribuciones a campañas, puedes ordenarlas y filtrarlas como quieras.",
|
||||
"headers": {
|
||||
"target": "Destino",
|
||||
"amount": "Importe",
|
||||
"origin": "Origen",
|
||||
"paymentMethod": "Método",
|
||||
"date": "Fecha",
|
||||
"chargeStatus": "Estado de la operación",
|
||||
"refundToWallet": "A Monedero"
|
||||
},
|
||||
"noData": "No hay datos disponibles",
|
||||
"details": {
|
||||
"operationTime": "Hora de la operación",
|
||||
"trackingCodes": {
|
||||
"title": "Códigos de seguimiento",
|
||||
"headers": {
|
||||
"title": "Título",
|
||||
"trackingCode": "Código de seguimiento"
|
||||
}
|
||||
},
|
||||
"toWallet": "A monedero",
|
||||
"platformLinks": {
|
||||
"title": "Enlace a la plataforma",
|
||||
"headers": {
|
||||
"type": "Tipo de Enlace",
|
||||
"rel": "Relación",
|
||||
"href": "Url"
|
||||
}
|
||||
},
|
||||
"estimatedFee": "Comisión estimada",
|
||||
"concept": "Concepto"
|
||||
},
|
||||
"actions": {
|
||||
"refundToWallet": "Devolución a monedero",
|
||||
"refundToUser": "Devolución a usuario",
|
||||
"changeUser": "Cambiar de usuario",
|
||||
"manageReward": "Gestionar recompensa"
|
||||
},
|
||||
"filters": {
|
||||
"order": {
|
||||
"title": "Ordenar por:",
|
||||
"options": {
|
||||
"date-desc": "Fecha (más reciente primero)",
|
||||
"date-asc": "Fecha (más antigua primero)",
|
||||
"amount-desc": "Importe (mayor a menor)",
|
||||
"amount-asc": "Importe (menor a mayor)",
|
||||
"status-desc": "Estado (Z-A)",
|
||||
"status-asc": "Estado (A-Z)"
|
||||
}
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"title": "Líneas por página:",
|
||||
"options": {
|
||||
"10": "10",
|
||||
"20": "20",
|
||||
"30": "30",
|
||||
"50": "50"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"btnLabel": "Buscar",
|
||||
"resultsFound": "{{totalItems}} resultados para \"{{query}}\"",
|
||||
"noResults": "No se han encontrado resultados"
|
||||
},
|
||||
"paymentMethod": {
|
||||
"title": "Método de pago",
|
||||
"options": {
|
||||
"all": "Todos",
|
||||
"paypal": "Paypal",
|
||||
"stripe": "Stripe",
|
||||
"wallet": "Monedero"
|
||||
}
|
||||
},
|
||||
"rangeAmount": {
|
||||
"title": "Selecciona importe",
|
||||
"options": {
|
||||
"500000": "+ 5.000€",
|
||||
"all": "Todos",
|
||||
"000..10000": "0€ - 100€",
|
||||
"10000..50000": "100€ - 500€",
|
||||
"50000..100000": "500€ - 1.000€",
|
||||
"100000..500000": "1.000€ - 5.000€"
|
||||
}
|
||||
},
|
||||
"dateRange": {
|
||||
"initDate": "Fecha Inicio",
|
||||
"endDate": "Fecha Fin",
|
||||
"from": "Desde",
|
||||
"to": "Hasta",
|
||||
"errors": {
|
||||
"invalidDate": "Fecha no válida",
|
||||
"invalidRange": "La fecha de inicio no puede ser mayor que la fecha fin."
|
||||
}
|
||||
},
|
||||
"chargeStatus": {
|
||||
"title": "Estado de Aporte",
|
||||
"options": {
|
||||
"all": "Todos",
|
||||
"to_charge": "Por cobrar",
|
||||
"in_charge": "Cobrado",
|
||||
"to_refund": "Por reembolsar",
|
||||
"refunded": "Reembolsado",
|
||||
"to_wallet": "Por reembolsar a monedero",
|
||||
"walleted": "Reembolsado a monedero"
|
||||
}
|
||||
},
|
||||
"projectStatus": {
|
||||
"title": "Estado de la campaña",
|
||||
"options": {
|
||||
"all": "Todas",
|
||||
"active": "Activas",
|
||||
"finished": "Finalizadas",
|
||||
"cancelled": "Canceladas"
|
||||
}
|
||||
},
|
||||
"btns": {
|
||||
"openFilters": "Filtros",
|
||||
"closeFilters": "Cerrar filtros",
|
||||
"reset": "Reiniciar filtros",
|
||||
"apply": "Aplicar filtros"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Mostrando {{items}} de {{total}}",
|
||||
"unloaded": "Mostrando...",
|
||||
"noResults": "No hay resultados"
|
||||
},
|
||||
"export": {
|
||||
"csv": "Exportar CSV",
|
||||
"exporting": "Exportando...",
|
||||
"error": "Error al exportar el archivo CSV. Por favor, inténtalo de nuevo.",
|
||||
"errorAuth": "Error de autenticación. Por favor, inicia sesión de nuevo.",
|
||||
"filename": "gateway-charges",
|
||||
"itemsText": "items",
|
||||
"noData": "No se encontraron datos para exportar"
|
||||
},
|
||||
"downloadButtons": {
|
||||
"details": "Descarga detalle",
|
||||
"certificates": "Descarga certificados",
|
||||
"pdf": "Descarga PDF"
|
||||
},
|
||||
"lastEdited": "Última modificación: {{date}} {{time}}",
|
||||
"annotations": "{{annotations}} Anotaciones"
|
||||
}
|
||||
},
|
||||
"static": {
|
||||
@@ -859,7 +1056,7 @@
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"_": "System messages (errors, notifications, validation, etc.)",
|
||||
"_": "Mensajes del sistema (errores, notificaciones, validación, etc.)",
|
||||
"loading": "Cargando...",
|
||||
"notImplemented": "Esta funcionalidad aún no está implementada. Estamos trabajando en ello, ¡gracias por tu paciencia!",
|
||||
"error": {
|
||||
@@ -878,7 +1075,7 @@
|
||||
"constraint": {
|
||||
"file": {
|
||||
"maxAllowedSize": "Tamaño máximo permitido 10MB",
|
||||
"supportedTypes": "Archivos compatibles: .png, .jpg"
|
||||
"supportedTypes": "Archivos compatibles: {{ types }}"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
@@ -1105,184 +1302,6 @@
|
||||
"createProject": "Crea tu proyecto",
|
||||
"goToProfile": "Ir a Perfil"
|
||||
},
|
||||
"contributions": {
|
||||
"title": "Aportes",
|
||||
"description": "Aquí tienes las últimas contribuciones a campañas, puedes ordenarlas y filtrarlas como quieras.",
|
||||
"table": {
|
||||
"headers": {
|
||||
"target": "Destino",
|
||||
"amount": "Importe",
|
||||
"origin": "Origen",
|
||||
"paymentMethod": "Método",
|
||||
"date": "Fecha",
|
||||
"chargeStatus": "Estado de la operación",
|
||||
"refundToWallet": "A Monedero"
|
||||
},
|
||||
"rows": {
|
||||
"payments": {
|
||||
"tpv": "Tarjeta",
|
||||
"paypal": "Paypal",
|
||||
"stripe": "Stripe",
|
||||
"wallet": "Monedero"
|
||||
},
|
||||
"status": {
|
||||
"to_charge": "Por cobrar",
|
||||
"in_charge": "Cobrado",
|
||||
"to_refund": "Por reembolsar",
|
||||
"refunded": "Reembolsado",
|
||||
"to_wallet": "Por reembolsar a monedero",
|
||||
"walleted": "Reembolsado a monedero"
|
||||
},
|
||||
"refund": {
|
||||
"to_wallet": "Sí",
|
||||
"to_gateway": "No"
|
||||
},
|
||||
"noData": "No hay datos disponibles"
|
||||
}
|
||||
},
|
||||
"grid": {
|
||||
"details": {
|
||||
"operationTime": "Hora de la operación",
|
||||
"trackingCodes": {
|
||||
"title": "Códigos de seguimiento",
|
||||
"headers": {
|
||||
"title": "Título",
|
||||
"trackingCode": "Código de seguimiento"
|
||||
}
|
||||
},
|
||||
"toWallet": "A monedero",
|
||||
"platformLinks": {
|
||||
"title": "Enlace a la plataforma",
|
||||
"headers": {
|
||||
"type": "Tipo de Enlace",
|
||||
"rel": "Relación",
|
||||
"href": "Url"
|
||||
}
|
||||
},
|
||||
"estimatedFee": "Comisión estimada",
|
||||
"concept": "Concepto"
|
||||
},
|
||||
"actions": {
|
||||
"refundToWallet": "Devolución a monedero",
|
||||
"refundToUser": "Devolución a usuario",
|
||||
"changeUser": "Cambiar de usuario",
|
||||
"manageReward": "Gestionar recompensa"
|
||||
},
|
||||
"content-footer": {
|
||||
"lastEdited": "Última modificación: {{date}} {{time}}",
|
||||
"downloadDetails": "Descarga detalle",
|
||||
"annotations": "{{annotations}} Anotaciones"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"order": {
|
||||
"title": "Ordenar por:",
|
||||
"options": {
|
||||
"date-desc": "Fecha (más reciente primero)",
|
||||
"date-asc": "Fecha (más antigua primero)",
|
||||
"amount-desc": "Importe (mayor a menor)",
|
||||
"amount-asc": "Importe (menor a mayor)",
|
||||
"status-desc": "Estado (Z-A)",
|
||||
"status-asc": "Estado (A-Z)"
|
||||
}
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"title": "Líneas por página:",
|
||||
"options": {
|
||||
"10": "10",
|
||||
"20": "20",
|
||||
"30": "30",
|
||||
"50": "50"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"btnLabel": "Buscar",
|
||||
"resultsFound": "{{totalItems}} resultados para \"{{query}}\"",
|
||||
"noResults": "No se han encontrado resultados",
|
||||
"labels": {
|
||||
"projects": "Proyectos",
|
||||
"users": "Usuarios",
|
||||
"tipjars": "Botes de propina",
|
||||
"tipjar-id": "ID del bote de propina"
|
||||
}
|
||||
},
|
||||
"paymentMethod": {
|
||||
"title": "Método de pago",
|
||||
"options": {
|
||||
"all": "Todos",
|
||||
"paypal": "Paypal",
|
||||
"stripe": "Stripe",
|
||||
"wallet": "Monedero"
|
||||
}
|
||||
},
|
||||
"rangeAmount": {
|
||||
"title": "Selecciona importe",
|
||||
"options": {
|
||||
"500000": "+ 5.000€",
|
||||
"all": "Todos",
|
||||
"000..10000": "0€ - 100€",
|
||||
"10000..50000": "100€ - 500€",
|
||||
"50000..100000": "500€ - 1.000€",
|
||||
"100000..500000": "1.000€ - 5.000€"
|
||||
}
|
||||
},
|
||||
"dateRange": {
|
||||
"initDate": "Fecha Inicio",
|
||||
"endDate": "Fecha Fin",
|
||||
"from": "Desde",
|
||||
"to": "Hasta",
|
||||
"errors": {
|
||||
"invalidDate": "Fecha no válida",
|
||||
"invalidRange": "La fecha de inicio no puede ser mayor que la fecha fin."
|
||||
}
|
||||
},
|
||||
"chargeStatus": {
|
||||
"title": "Estado de Aporte",
|
||||
"options": {
|
||||
"all": "Todos",
|
||||
"to_charge": "Por cobrar",
|
||||
"in_charge": "Cobrado",
|
||||
"to_refund": "Por reembolsar",
|
||||
"refunded": "Reembolsado",
|
||||
"to_wallet": "Por reembolsar a monedero",
|
||||
"walleted": "Reembolsado a monedero"
|
||||
}
|
||||
},
|
||||
"projectStatus": {
|
||||
"title": "Estado de la campaña",
|
||||
"options": {
|
||||
"all": "Todas",
|
||||
"active": "Activas",
|
||||
"finished": "Finalizadas",
|
||||
"cancelled": "Canceladas"
|
||||
}
|
||||
},
|
||||
"btns": {
|
||||
"openFilters": "Filtros",
|
||||
"closeFilters": "Cerrar filtros",
|
||||
"reset": "Reiniciar filtros",
|
||||
"apply": "Aplicar filtros"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Mostrando {{items}} de {{total}}",
|
||||
"unloaded": "Mostrando...",
|
||||
"noResults": "No hay resultados"
|
||||
},
|
||||
"tootip": {
|
||||
"copied": "¡Copiado!"
|
||||
},
|
||||
"export": {
|
||||
"csv": "Exportar CSV",
|
||||
"exporting": "Exportando...",
|
||||
"error": "Error al exportar el archivo CSV. Por favor, inténtalo de nuevo.",
|
||||
"errorAuth": "Error de autenticación. Por favor, inicia sesión de nuevo.",
|
||||
"filename": "gateway-charges",
|
||||
"itemsText": "items",
|
||||
"noData": "No se encontraron datos para exportar"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"projects": {
|
||||
"totalizers": {
|
||||
@@ -1291,14 +1310,6 @@
|
||||
"totalPaid": "Total pagado:",
|
||||
"totalUnpaid": "Falta por pagar:"
|
||||
}
|
||||
},
|
||||
"charges": {
|
||||
"lastContributions": "Últimos aportes:",
|
||||
"totalizers": {
|
||||
"totalCharges": "Total aportes:",
|
||||
"totalTips": "Total propinas:",
|
||||
"totalFees": "Comisiones totales:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
@@ -1347,6 +1358,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"share": "Compartir",
|
||||
"shareModal": {
|
||||
"title": "Comparte este perfil",
|
||||
"description": "Puedes copiar el enlace para enviarlo y también compartirlo en Facebook o X."
|
||||
},
|
||||
"subscribe": "Subscríbete",
|
||||
"tabs": {
|
||||
"about": "Sobre el perfil",
|
||||
@@ -1359,6 +1374,7 @@
|
||||
"organizationIntro": "Organización comprometida con el cambio social",
|
||||
"bioSample": "Con una trayectoria dedicada al ecosistema de crowdfunding y financiación colectiva, {{name}} se ha consolidado como un referente en la creación de proyectos colaborativos y la movilización de recursos. Su visión se centra en potenciar iniciativas que transforman realidades locales, combinando estrategias innovadoras con un profundo compromiso por la participación ciudadana y la equidad social.",
|
||||
"noProjects": "No hay proyectos impulsados aún.",
|
||||
"noBio": "Todavía no hay información sobre este perfil.",
|
||||
"donorType": {
|
||||
"fan": {
|
||||
"title": "Fan",
|
||||
@@ -1464,4 +1480,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import Contributions from "../../../components/Admin/Contributions.svelte";
|
||||
<div class="flex w-full flex-col gap-10 py-10">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-[2.5rem]/12 font-bold tracking-[0%] text-black">
|
||||
{t("contributions.title")}
|
||||
{t("pages.admin.charges.title")}
|
||||
</h2>
|
||||
<p class="text-content">{t("contributions.description")}</p>
|
||||
<p class="text-content">{t("pages.admin.charges.description")}</p>
|
||||
</div>
|
||||
|
||||
<Contributions client:load />
|
||||
|
||||
@@ -15,18 +15,20 @@ import type { Campaign } from "../../../types/campaign";
|
||||
|
||||
const { t, lang, session } = Astro.locals;
|
||||
|
||||
const mapProjectToCard = (project: Project): Campaign => ({
|
||||
slug: project.slug || "",
|
||||
title: project.title || "",
|
||||
subtitle: project.subtitle || "",
|
||||
description: project.description || "",
|
||||
image: project.video?.thumbnail || "/images/placeholder-project.jpg",
|
||||
obtained: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
|
||||
minimum: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
|
||||
categories: project.categories || [],
|
||||
territory: project.territory || "",
|
||||
size: "small",
|
||||
});
|
||||
const mapProjectToCard = (project: Project) =>
|
||||
({
|
||||
id: project.slug || "",
|
||||
slug: project.slug || "",
|
||||
title: project.title || "",
|
||||
subtitle: project.subtitle || "",
|
||||
description: project.description || "",
|
||||
image: project.video?.thumbnail || "/images/placeholder-project.jpg",
|
||||
obtained: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
|
||||
minimum: project.budget?.minimum?.money || { amount: 0, currency: "EUR" },
|
||||
categories: project.categories || [],
|
||||
territory: project.territory || "",
|
||||
size: "small",
|
||||
}) as unknown as Campaign;
|
||||
|
||||
let donatedProjects: Campaign[] = [];
|
||||
let suggestedProjects: Campaign[] = [];
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import {
|
||||
draft,
|
||||
validationErrors,
|
||||
touchedFields,
|
||||
isFormValid,
|
||||
validateField,
|
||||
validateForm,
|
||||
markFieldAsTouched,
|
||||
} from "./project-draft";
|
||||
import BaseCard from "../../../components/BaseCard.svelte";
|
||||
import Button from "../../../components/library/Button.svelte";
|
||||
import CategorySelect from "../../../components/library/CategorySelect.svelte";
|
||||
@@ -17,49 +8,99 @@
|
||||
import DateInput from "../../../components/library/DateInput.svelte";
|
||||
import TextInput from "../../../components/library/TextInput.svelte";
|
||||
import { t } from "../../../i18n/store";
|
||||
import {
|
||||
apiProjectsPost,
|
||||
type Category,
|
||||
type ProjectProjectCreationDto,
|
||||
} from "../../../openapi/client";
|
||||
import { client } from "../../../openapi/client/client.gen";
|
||||
import { apiCategoriesIdGetUrl } from "../../../openapi/client/paths.gen";
|
||||
import {
|
||||
validateCreateForm,
|
||||
validateField,
|
||||
type ValidationErrors,
|
||||
} from "../../../stores/drafts/draftValidation";
|
||||
import {
|
||||
createDraft,
|
||||
currentDraft,
|
||||
markFieldAsTouched,
|
||||
touchedFields,
|
||||
updateProject,
|
||||
isCreateFormValid,
|
||||
project,
|
||||
validationErrors,
|
||||
} from "../../../stores/drafts/projectDraft";
|
||||
import { formatCurrency } from "../../../utils/currencies";
|
||||
|
||||
import type { ProjectDraft } from "./project-draft";
|
||||
import type { Category } from "../../../openapi/client";
|
||||
|
||||
// import { apiProjectsPost } from "../../../openapi/client"; // TODO: Use when API integration is complete
|
||||
let releaseDate = $state($project.release ? new Date($project.release) : new Date());
|
||||
|
||||
let { categories }: { categories: Category[] } = $props();
|
||||
|
||||
// Track if form has been submitted once (for showing all errors)
|
||||
let submitted = false;
|
||||
let submitted = $state(false);
|
||||
|
||||
// Loading state for form submission
|
||||
let isSubmitting = false;
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// API error message
|
||||
let apiError: string | null = null;
|
||||
let apiError: string | null = $state(null);
|
||||
|
||||
// Success state
|
||||
let submitSuccess = false;
|
||||
let submitSuccess = $state(false);
|
||||
|
||||
// Debounce timer for real-time validation
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleFieldBlur(fieldName: keyof ProjectDraft) {
|
||||
function handleFieldBlur(fieldName: keyof ProjectProjectCreationDto) {
|
||||
if (!$currentDraft) return;
|
||||
|
||||
markFieldAsTouched(fieldName);
|
||||
validateField(fieldName, $draft[fieldName]);
|
||||
const error = validateField(fieldName, $currentDraft.createProject[fieldName]);
|
||||
|
||||
validationErrors.update((errors: ValidationErrors) => {
|
||||
if (error) {
|
||||
return {
|
||||
...errors,
|
||||
title: error,
|
||||
};
|
||||
}
|
||||
|
||||
const { title, ...rest } = errors;
|
||||
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles field value changes with type-safe updates.
|
||||
* Uses generic typing to ensure type safety without bypassing TypeScript checks.
|
||||
*/
|
||||
function handleFieldChange<K extends keyof ProjectDraft>(fieldName: K, value: ProjectDraft[K]) {
|
||||
function handleFieldChange<K extends keyof ProjectProjectCreationDto>(
|
||||
fieldName: K,
|
||||
value: ProjectProjectCreationDto[K],
|
||||
) {
|
||||
// Update the draft value with proper typing
|
||||
$draft[fieldName] = value;
|
||||
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(() => {
|
||||
validateField(fieldName, value);
|
||||
const error = validateField(fieldName, value);
|
||||
|
||||
validationErrors.update((errors: ValidationErrors) => {
|
||||
if (error) {
|
||||
return {
|
||||
...errors,
|
||||
title: error,
|
||||
};
|
||||
}
|
||||
|
||||
const { title, ...rest } = errors;
|
||||
|
||||
return rest;
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
@@ -73,16 +114,26 @@
|
||||
});
|
||||
|
||||
function handleCategoryChange(selected: Category[]) {
|
||||
const categoryIds = selected.map((s) => s.id.toString());
|
||||
handleFieldChange("categories", categoryIds);
|
||||
const categoryIris = selected.map((s) => {
|
||||
return client.buildUrl({ url: apiCategoriesIdGetUrl, path: { id: s.id } });
|
||||
});
|
||||
|
||||
handleFieldChange("categories", categoryIris);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
submitted = true;
|
||||
apiError = null;
|
||||
|
||||
if (termsAccepted === false) {
|
||||
submitSuccess = false;
|
||||
$validationErrors["termsAccepted"] = "validation.project.terms.required";
|
||||
}
|
||||
|
||||
const errors = validateCreateForm($project);
|
||||
|
||||
// Validate entire form
|
||||
const isValid = validateForm();
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
|
||||
if (!isValid) {
|
||||
// Scroll to error summary
|
||||
@@ -102,6 +153,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$currentDraft) {
|
||||
apiError = "Draft not initialized";
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit to API
|
||||
isSubmitting = true;
|
||||
|
||||
@@ -111,50 +167,48 @@
|
||||
// The API expects: title, subtitle, category (single), territory, description
|
||||
// Need to update the form to collect the correct fields or map them appropriately
|
||||
|
||||
// For now, simulate submission in dev mode
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Form is valid, would submit:", $draft);
|
||||
// Simulate API delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
submitSuccess = true;
|
||||
} else {
|
||||
// TODO: Implement actual API submission when form fields match API requirements
|
||||
/*
|
||||
const { data, error } = await apiProjectsPost({
|
||||
body: {
|
||||
title: $draft.title,
|
||||
subtitle: $draft.subtitle,
|
||||
category: $draft.categories[0], // Map first category
|
||||
territory: // Need territory field
|
||||
description: // Need description field
|
||||
},
|
||||
});
|
||||
// TODO: Implement actual API submission when form fields match API requirements
|
||||
|
||||
if (error) {
|
||||
// Handle API validation errors
|
||||
if ('violations' in error && error.violations) {
|
||||
error.violations.forEach((violation) => {
|
||||
const field = violation.propertyPath as keyof ProjectDraft;
|
||||
if (field) {
|
||||
$validationErrors[field] = violation.message || 'Invalid value';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
apiError = "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
} else if (data) {
|
||||
submitSuccess = true;
|
||||
// Redirect to project page
|
||||
const { data, error } = await apiProjectsPost({
|
||||
baseUrl: "/api/relay",
|
||||
body: {
|
||||
title: $currentDraft.createProject.title,
|
||||
subtitle: $currentDraft.createProject.subtitle,
|
||||
categories: $currentDraft.createProject.categories, // Map all categories
|
||||
release: $currentDraft.createProject.release, // Map release date
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// Handle API validation errors
|
||||
if ("violations" in error && error.violations) {
|
||||
error.violations.forEach((violation) => {
|
||||
const field = violation.propertyPath as keyof ProjectProjectCreationDto;
|
||||
if (field) {
|
||||
$validationErrors[field] = violation.message || "Invalid value";
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
apiError = "An unexpected error occurred. Please try again.";
|
||||
return;
|
||||
}
|
||||
*/
|
||||
apiError = "Project creation is not yet fully implemented.";
|
||||
} else if (data) {
|
||||
submitSuccess = true;
|
||||
updateProject({
|
||||
title: $currentDraft.createProject.title,
|
||||
subtitle: $currentDraft.createProject.subtitle,
|
||||
categories: $currentDraft.createProject.categories,
|
||||
release: $currentDraft.createProject.release,
|
||||
});
|
||||
currentDraft.update((d) => (d ? { ...d, status: "project-created" } : d));
|
||||
|
||||
// Redirect to project page
|
||||
window.location.href = `/project/${data.id}/edit`;
|
||||
}
|
||||
} catch (err) {
|
||||
// Handle unexpected errors
|
||||
apiError = "An unexpected error occurred. Please try again.";
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Unexpected error:", err);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
@@ -171,10 +225,27 @@
|
||||
minDate.setDate(minDate.getDate() + 14);
|
||||
return minDate;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!$currentDraft) {
|
||||
createDraft({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
categories: [],
|
||||
release: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let termsAccepted: boolean = false;
|
||||
|
||||
function toggleTermsAcceptance() {
|
||||
termsAccepted = !termsAccepted;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="wrapper md:flex md:flex-row">
|
||||
<div class="flex max-w-167 flex-col gap-10">
|
||||
<div class="mb-20 flex max-w-167 flex-col gap-10">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-3xl font-bold text-black lg:text-4xl">
|
||||
{$t("pages.project.create.title")}
|
||||
@@ -227,8 +298,10 @@
|
||||
<TextInput
|
||||
name="title"
|
||||
placeholder={$t("pages.project.create.description.titlePrompt")}
|
||||
bind:value={$draft.title}
|
||||
bind:value={$project.title}
|
||||
error={shouldShowError("title") ? $t($validationErrors.title) : undefined}
|
||||
onBlur={() => handleFieldBlur("title")}
|
||||
onInput={(e) => handleFieldChange("title", (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
@@ -240,8 +313,10 @@
|
||||
)
|
||||
? 'border-red-500'
|
||||
: 'border-[#855a96]'}"
|
||||
bind:value={$draft.subtitle}
|
||||
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>
|
||||
@@ -275,7 +350,7 @@
|
||||
</p>
|
||||
<DateInput
|
||||
name="release"
|
||||
bind:value={$draft.release}
|
||||
bind:value={releaseDate}
|
||||
min={getMinDate()}
|
||||
error={shouldShowError("release") ? $t($validationErrors.release) : undefined}
|
||||
onBlur={() => handleFieldBlur("release")}
|
||||
@@ -290,38 +365,50 @@
|
||||
{#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">
|
||||
Project draft created successfully! (Dev mode simulation)
|
||||
{$t("pages.project.create.submitSuccess")}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
bind:checked={$draft.termsAccepted}
|
||||
onchange={() => handleFieldChange("termsAccepted", $draft.termsAccepted)}
|
||||
>
|
||||
<Checkbox bind:checked={termsAccepted} onchange={() => toggleTermsAcceptance}>
|
||||
<span class="text-secondary">
|
||||
<a href="#" class="underline">{$t("pages.project.create.terms.label")}</a>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<p>
|
||||
<Button size="md" disabled={!$isFormValid || isSubmitting} onclick={handleSubmit}>
|
||||
{isSubmitting ? "Submitting..." : $t("pages.project.create.submit")}
|
||||
<Button size="md" disabled={!$isCreateFormValid || isSubmitting} onclick={handleSubmit}>
|
||||
{isSubmitting
|
||||
? $t("pages.project.create.submitting")
|
||||
: $t("pages.project.create.submit")}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<BaseCard class="flex h-full max-h-126.5 w-full max-w-109.25 flex-col">
|
||||
<h1 class="text-secondary text-2xl leading-8 font-bold {$draft.title || 'opacity-50'}">
|
||||
{$draft.title || $t("pages.project.create.description.titlePlaceholder")}
|
||||
<BaseCard
|
||||
class="border-grey flex h-full max-h-126.5 w-full max-w-109.25 flex-col bg-white"
|
||||
style="box-shadow: 0 35px 10px 0 rgba(0, 0, 0, 0.00), 0 22px 9px 0 rgba(0, 0, 0, 0.01), 0 13px 8px 0 rgba(0, 0, 0, 0.05), 0 6px 6px 0 rgba(0, 0, 0, 0.09), 0 1px 3px 0 rgba(0, 0, 0, 0.10);"
|
||||
>
|
||||
<h1
|
||||
class="text-secondary text-double mb-2 leading-10 font-bold {$currentDraft
|
||||
?.createProject.title || 'opacity-24'}"
|
||||
>
|
||||
{$currentDraft?.createProject.title ||
|
||||
$t("pages.project.create.description.titlePlaceholder")}
|
||||
</h1>
|
||||
<p class="text-sm text-black">
|
||||
{$draft.subtitle || $t("pages.project.create.description.subtitlePlaceholder")}
|
||||
<p
|
||||
class="text-content overflow-hidden text-base font-normal text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{$currentDraft?.createProject.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($draft.budget)}
|
||||
{formatCurrency(
|
||||
$currentDraft?.wizardForm.budget?.minimum?.money?.amount || 0,
|
||||
"EUR",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { derived, writable, get } from "svelte/store";
|
||||
import { z } from "zod";
|
||||
|
||||
import { projectCreationSchema } from "./validation";
|
||||
|
||||
export interface ProjectDraft {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
categories: string[];
|
||||
budget: number;
|
||||
release: Date;
|
||||
termsAccepted: boolean;
|
||||
}
|
||||
|
||||
// Default release date: 28 days from now
|
||||
const getDefaultReleaseDate = () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 28);
|
||||
return date;
|
||||
};
|
||||
|
||||
export const draft = writable<ProjectDraft>({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
categories: [],
|
||||
budget: 0, // Budget is displayed but not validated
|
||||
release: getDefaultReleaseDate(),
|
||||
termsAccepted: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation errors store.
|
||||
* Maps field names to their error messages (translation keys).
|
||||
*/
|
||||
export const validationErrors = writable<Record<string, string>>({});
|
||||
|
||||
/**
|
||||
* Tracks which fields have been touched (blurred).
|
||||
* Only touched fields will show validation errors.
|
||||
*/
|
||||
export const touchedFields = writable<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Derived store that indicates if the form is valid.
|
||||
* Returns true only when:
|
||||
* 1. All required fields have values
|
||||
* 2. There are no validation errors
|
||||
*/
|
||||
export const isFormValid = derived([draft, validationErrors], ([$draft, $errors]) => {
|
||||
// Check if there are any validation errors
|
||||
if (Object.keys($errors).length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that required fields have values
|
||||
const hasTitle = $draft.title.trim().length > 0;
|
||||
const hasSubtitle = $draft.subtitle.trim().length > 0;
|
||||
const hasCategories = $draft.categories.length > 0;
|
||||
const hasTerms = $draft.termsAccepted === true;
|
||||
|
||||
return hasTitle && hasSubtitle && hasCategories && hasTerms;
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates a single field and updates the validation errors store.
|
||||
* Uses Zod's type system with proper type guards to avoid unsafe type assertions.
|
||||
* @param fieldName - The name of the field to validate
|
||||
* @param value - The current value of the field
|
||||
*/
|
||||
export function validateField(fieldName: keyof ProjectDraft, value: unknown) {
|
||||
// Type guard to check if field exists in schema
|
||||
type SchemaFields = keyof typeof projectCreationSchema.shape;
|
||||
|
||||
if (!(fieldName in projectCreationSchema.shape)) {
|
||||
// Field not in schema, skip validation
|
||||
return;
|
||||
}
|
||||
|
||||
// Now we know fieldName exists in the schema
|
||||
const fieldSchema = projectCreationSchema.shape[fieldName as SchemaFields];
|
||||
|
||||
// Use Zod's ZodTypeAny for proper type handling
|
||||
const result = (fieldSchema as z.ZodTypeAny).safeParse(value);
|
||||
|
||||
validationErrors.update((errors) => {
|
||||
if (!result.success) {
|
||||
// Extract the first error message
|
||||
const errorMessage = result.error.issues[0]?.message || "";
|
||||
return { ...errors, [fieldName]: errorMessage };
|
||||
} else {
|
||||
// Remove error if validation passes
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [fieldName]: _, ...rest } = errors;
|
||||
return rest;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the entire form and updates the validation errors store.
|
||||
* Uses get() helper to avoid manual subscription management.
|
||||
* Returns true if the form is valid, false otherwise.
|
||||
*/
|
||||
export function validateForm(): boolean {
|
||||
const currentDraft = get(draft);
|
||||
const result = projectCreationSchema.safeParse(currentDraft);
|
||||
|
||||
if (!result.success) {
|
||||
const errors: Record<string, string> = {};
|
||||
result.error.issues.forEach((issue) => {
|
||||
const fieldName = issue.path[0] as string;
|
||||
if (!errors[fieldName]) {
|
||||
errors[fieldName] = issue.message;
|
||||
}
|
||||
});
|
||||
validationErrors.set(errors);
|
||||
return false;
|
||||
} else {
|
||||
validationErrors.set({});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a field as touched.
|
||||
* @param fieldName - The name of the field that was touched
|
||||
*/
|
||||
export function markFieldAsTouched(fieldName: string) {
|
||||
touchedFields.update((fields) => {
|
||||
const newFields = new Set(fields);
|
||||
newFields.add(fieldName);
|
||||
return newFields;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the form to its initial state.
|
||||
*/
|
||||
export function resetForm() {
|
||||
draft.set({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
categories: [],
|
||||
budget: 0,
|
||||
release: getDefaultReleaseDate(),
|
||||
termsAccepted: false,
|
||||
});
|
||||
validationErrors.set({});
|
||||
touchedFields.set(new Set());
|
||||
}
|
||||
@@ -69,10 +69,6 @@ export const projectCreationSchema = z.object({
|
||||
* Not required for form submission but included for type consistency.
|
||||
*/
|
||||
budget: z.number().nonnegative().optional().default(0),
|
||||
|
||||
termsAccepted: z.boolean().refine((val) => val === true, {
|
||||
message: "validation.project.terms.required",
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
---
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import App from "../../../components/App.svelte";
|
||||
import ProfileHero from "../../../components/profile/ProfileHero.astro";
|
||||
import ProfileInfo from "../../../components/profile/ProfileInfo.svelte";
|
||||
import Tabs from "../../../components/Tabs.svelte";
|
||||
import ProfileProjectsList from "../../../components/profile/ProfileProjectsList.astro";
|
||||
import ProfileDonorType from "../../../components/profile/ProfileDonorType.svelte";
|
||||
import { renderMarkdown } from "../../../utils/renderMarkdown";
|
||||
import {
|
||||
apiUsersIdOrHandleGet,
|
||||
apiProjectSupportsGetCollection,
|
||||
apiProjectSupportsmoneyTotalGetCollection,
|
||||
type Accounting,
|
||||
type Money,
|
||||
type ProjectSupport,
|
||||
} from "../../../openapi/client";
|
||||
import type { User } from "../../../openapi/client/types.gen";
|
||||
import type { Campaign } from "../../../types/campaign";
|
||||
import { getLanguage } from "../../../middleware/utils";
|
||||
import { client } from "../../../openapi/client/client.gen";
|
||||
|
||||
const { idOrHandle } = Astro.params;
|
||||
const { t, lang, session } = Astro.locals;
|
||||
|
||||
if (!idOrHandle) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
// Fetch user data
|
||||
let user: User | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
try {
|
||||
const userResponse = await apiUsersIdOrHandleGet({
|
||||
path: { idOrHandle: idOrHandle },
|
||||
});
|
||||
|
||||
if (userResponse.error) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
user = userResponse.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
errorMessage = t("profile.notFound");
|
||||
}
|
||||
|
||||
// Prepare profile data with proper fallbacks
|
||||
const displayName = user?.displayName || user?.handle || "User";
|
||||
|
||||
// Location - API doesn't have location field yet
|
||||
// TODO: Add location field to Person/Organization in API
|
||||
const location = undefined;
|
||||
|
||||
// Bio - API doesn't have bio/description field yet
|
||||
// TODO: Add bio/description field to User in API
|
||||
const bio = undefined;
|
||||
|
||||
const tabs = [
|
||||
{ id: "about", label: t("profile.tabs.about") },
|
||||
{ id: "projects", label: t("profile.tabs.projects") },
|
||||
{ id: "donorType", label: t("profile.tabs.donorType") },
|
||||
];
|
||||
|
||||
// Fetch user's backed/supported projects from API
|
||||
let userSupports: ProjectSupport[] = [];
|
||||
let totalDonated: { amount: number; currency: string } = { amount: 0, currency: "EUR" };
|
||||
let projectsDonatedCount = 0;
|
||||
|
||||
// Users are guaranteed to have an accounting resource
|
||||
if (user) {
|
||||
try {
|
||||
// Get all project supports for this user
|
||||
const { data: supports } = await apiProjectSupportsGetCollection({
|
||||
query: {
|
||||
origin: user.accounting!,
|
||||
},
|
||||
});
|
||||
|
||||
if (supports) {
|
||||
userSupports = supports;
|
||||
projectsDonatedCount = supports.length;
|
||||
|
||||
// Get total money donated
|
||||
const { data: moneyTotal } = await apiProjectSupportsmoneyTotalGetCollection({
|
||||
query: {
|
||||
origin: user.accounting!,
|
||||
},
|
||||
});
|
||||
|
||||
if (moneyTotal?.amount && moneyTotal?.currency) {
|
||||
totalDonated = {
|
||||
amount: Number(moneyTotal.amount),
|
||||
currency: moneyTotal.currency,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user supports:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch actual projects from the supports
|
||||
const projectIRIs = userSupports
|
||||
.slice(0, 10)
|
||||
.map((support) => support.project)
|
||||
.filter(Boolean);
|
||||
let campaigns: Campaign[] = [];
|
||||
|
||||
if (projectIRIs.length > 0) {
|
||||
campaigns = await Promise.all(
|
||||
projectIRIs.map(async (projectIRI) => {
|
||||
try {
|
||||
// Fetch project data directly using IRI
|
||||
const { data: project } = (await client.get({
|
||||
url: client.buildUrl({ url: projectIRI! }),
|
||||
headers: {
|
||||
"Accept-Language": getLanguage(Astro),
|
||||
},
|
||||
})) as { data: any };
|
||||
|
||||
// Fetch accounting data
|
||||
const { data: accounting } = (await client.get({
|
||||
url: client.buildUrl({ url: project.accounting! }),
|
||||
})) as { data: Accounting };
|
||||
|
||||
return {
|
||||
size: "small",
|
||||
id: project.slug!,
|
||||
title: project.title!,
|
||||
image: project.video?.thumbnail!,
|
||||
minimum: project.budget?.minimum?.money!,
|
||||
obtained: accounting!.balance as Money,
|
||||
optimum: project.budget?.optimum?.money,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching project ${projectIRI}:`, error);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
).then((results) => results.filter(Boolean) as Campaign[]);
|
||||
}
|
||||
|
||||
// Social links - would come from user.socialMedia or similar field
|
||||
// API doesn't have social media fields yet
|
||||
// TODO: Add social media fields to User in API
|
||||
const socialLinks = user
|
||||
? {
|
||||
twitter: undefined,
|
||||
instagram: undefined,
|
||||
facebook: undefined,
|
||||
linkedin: undefined,
|
||||
email: user.email ? `mailto:${user.email}` : undefined,
|
||||
medium: undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Images with fallbacks
|
||||
const coverImage = undefined; // API doesn't provide cover image, use default
|
||||
const avatarImage = user?.avatar || undefined; // Use user avatar or default placeholder
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`${displayName} - ${t("pages.profile.title")}`}
|
||||
description={`${t("profile.tabs.about")} ${displayName}`}
|
||||
>
|
||||
<App client:load locale={lang} session={session} class="mb-20">
|
||||
{
|
||||
errorMessage ? (
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<p class="text-tertiary text-xl">{errorMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex w-full flex-col items-center gap-10">
|
||||
{/* Hero Section with Cover and Avatar */}
|
||||
<ProfileHero coverImage={coverImage} avatarImage={avatarImage} />
|
||||
|
||||
{/* User Info Section */}
|
||||
<ProfileInfo
|
||||
displayName={displayName}
|
||||
location={location}
|
||||
socialLinks={socialLinks}
|
||||
client:load
|
||||
/>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div class="mt-8 w-full">
|
||||
<Tabs tabs={tabs} client:load />
|
||||
</div>
|
||||
|
||||
{/* Tab Content - About */}
|
||||
<div data-tab-content="about">
|
||||
{bio && (
|
||||
<div class="mx-auto mt-10 w-full max-w-224.5 px-4">
|
||||
<div
|
||||
class="prose prose-base text-tertiary leading-6"
|
||||
set:html={renderMarkdown(bio)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Projects */}
|
||||
<div data-tab-content="projects" style="display: none;">
|
||||
{campaigns.length > 0 ? (
|
||||
<div class="mx-auto mt-10 w-full max-w-300 px-4">
|
||||
<ProfileProjectsList campaigns={campaigns} />
|
||||
</div>
|
||||
) : (
|
||||
<div class="mx-auto mt-10 w-full max-w-224.5 px-4 text-center">
|
||||
<p class="text-tertiary text-base">{t("profile.noProjects")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Donor Type */}
|
||||
<div data-tab-content="donorType" class="w-full" style="display: none;">
|
||||
<div class="mx-auto w-full max-w-300 px-4">
|
||||
<ProfileDonorType
|
||||
projectsDonated={projectsDonatedCount}
|
||||
moneyDonatedAmount={totalDonated.amount}
|
||||
moneyDonatedCurrency={totalDonated.currency}
|
||||
client:load
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</App>
|
||||
</Layout>
|
||||
@@ -8,38 +8,43 @@
|
||||
- URL query parameter sync
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import ProjectEditorShell from "./ProjectEditorShell.svelte";
|
||||
import { getStepComponent } from "./steps";
|
||||
import {
|
||||
apiProjectBudgetItemsPost,
|
||||
apiProjectCollaborationsPost,
|
||||
apiProjectRewardsPost,
|
||||
apiProjectsIdPatch,
|
||||
type Project,
|
||||
} from "../../../../../openapi/client";
|
||||
import { session } from "../../../../../auth/store";
|
||||
import { type Project, type ProjectProjectCreationDto } from "../../../../../openapi/client";
|
||||
import { apiProjectsGetCollectionUrl } from "../../../../../openapi/client/paths.gen";
|
||||
import {
|
||||
wizardState,
|
||||
initializeFromProject,
|
||||
clearLocalStorage,
|
||||
} from "../../../../../stores/wizard-state";
|
||||
currentDraft,
|
||||
deleteCurrentDraft,
|
||||
hasUnsavedChanges,
|
||||
initializeProjectDraft,
|
||||
loadDraft,
|
||||
markCurrentDraftClean,
|
||||
updateWizard,
|
||||
} from "../../../../../stores/drafts/projectDraft";
|
||||
import { publishDraft } from "../../../../../utils/projectPublisher";
|
||||
import { getProjectDraftResources } from "../../../../../utils/projectSubmissionApi";
|
||||
|
||||
import type { Session } from "../../../../../auth/types";
|
||||
|
||||
let {
|
||||
project,
|
||||
session,
|
||||
idOrSlug,
|
||||
project = null,
|
||||
}: {
|
||||
project: Project;
|
||||
session: Session;
|
||||
idOrSlug: string;
|
||||
project?: Project | null;
|
||||
} = $props();
|
||||
|
||||
// Initialize wizard state from project and set up URL sync
|
||||
$effect(() => {
|
||||
// Read URL parameter first (before initializing)
|
||||
let resolvedProject = $state<Project | null>(project);
|
||||
let isInitialized = $state(false);
|
||||
let showSessionErrorToast = $state(false);
|
||||
const user = $derived(($session: Session | null) => $session?.user);
|
||||
|
||||
function getInitialStep() {
|
||||
let initialStep = 1;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
const stepParam = url.searchParams.get("step");
|
||||
@@ -51,17 +56,84 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize from project
|
||||
initializeFromProject(project);
|
||||
return initialStep;
|
||||
}
|
||||
|
||||
// Set the step from URL parameter if present
|
||||
if (initialStep !== 1) {
|
||||
wizardState.update((state) => ({
|
||||
...state,
|
||||
currentStep: initialStep,
|
||||
}));
|
||||
function projectToDraft(project: Project): ProjectProjectCreationDto {
|
||||
return {
|
||||
title: project.title || "",
|
||||
subtitle: project.subtitle || "",
|
||||
categories: project.categories,
|
||||
release: project.calendar?.release ?? undefined,
|
||||
status: project.status || "in_draft",
|
||||
};
|
||||
}
|
||||
|
||||
function projectToIri(project: Project) {
|
||||
return `${apiProjectsGetCollectionUrl}/${project.slug ?? project.id}`;
|
||||
}
|
||||
|
||||
function draftToProject(idOrSlug: string): Project | null {
|
||||
if (!$currentDraft) return null;
|
||||
|
||||
const numericId = Number(idOrSlug);
|
||||
|
||||
return {
|
||||
id: Number.isNaN(numericId) ? undefined : numericId,
|
||||
slug: Number.isNaN(numericId) ? idOrSlug : undefined,
|
||||
title: $currentDraft.createProject.title,
|
||||
subtitle: $currentDraft.createProject.subtitle,
|
||||
categories: $currentDraft.createProject.categories,
|
||||
territory: {} as Project["territory"],
|
||||
description: "",
|
||||
deadline: $currentDraft.wizardForm.configuration.projectDeadline,
|
||||
budget: $currentDraft.wizardForm.budget,
|
||||
status: $currentDraft.createProject.status,
|
||||
};
|
||||
}
|
||||
|
||||
function redirectToNotFound() {
|
||||
window.location.href = "/404";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const initialStep = getInitialStep();
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
if (project?.id) {
|
||||
const resources = $session
|
||||
? await getProjectDraftResources(projectToIri(project), $session)
|
||||
: undefined;
|
||||
|
||||
await initializeProjectDraft(
|
||||
projectToDraft(project),
|
||||
String(project.id),
|
||||
resources,
|
||||
);
|
||||
resolvedProject = project;
|
||||
} else {
|
||||
const hasDraft = await loadDraft(idOrSlug);
|
||||
|
||||
if (!hasDraft) {
|
||||
redirectToNotFound();
|
||||
return;
|
||||
}
|
||||
|
||||
hasUnsavedChanges.set(true);
|
||||
resolvedProject = draftToProject(idOrSlug);
|
||||
}
|
||||
|
||||
updateWizard({ currentStep: initialStep }, { markUnsaved: false });
|
||||
isInitialized = true;
|
||||
} catch (err) {
|
||||
errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
|
||||
// Listen for browser back/forward navigation (client-side only)
|
||||
if (typeof window !== "undefined") {
|
||||
const handlePopState = () => {
|
||||
@@ -70,10 +142,7 @@
|
||||
if (stepParam) {
|
||||
const step = parseInt(stepParam, 10);
|
||||
if (!isNaN(step) && step >= 1 && step <= 6) {
|
||||
wizardState.update((state) => ({
|
||||
...state,
|
||||
currentStep: step,
|
||||
}));
|
||||
updateWizard({ currentStep: step });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -87,140 +156,50 @@
|
||||
});
|
||||
|
||||
// Reactive current step
|
||||
const currentStep = $derived($wizardState.currentStep);
|
||||
let saveState = $state<"idle" | "saving" | "saved">("idle");
|
||||
const currentStep = $derived($currentDraft?.wizardForm.currentStep ?? 1);
|
||||
let errorMessage = $state("");
|
||||
|
||||
/**
|
||||
* Handle save draft to API
|
||||
*/
|
||||
async function handleSave() {
|
||||
saveState = "saving";
|
||||
async function saveToAPI() {
|
||||
if (!$currentDraft) return;
|
||||
if (!resolvedProject?.id) {
|
||||
errorMessage = "Project not found in API";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentData = get(wizardState);
|
||||
const projectIri =
|
||||
apiProjectsGetCollectionUrl + "/" + (project.slug ? project.slug : project.id);
|
||||
|
||||
if (currentData.rewards.length > 0) {
|
||||
currentData.rewards.forEach(async (reward) => {
|
||||
const { error: rewardErr } = await apiProjectRewardsPost({
|
||||
body: {
|
||||
project: projectIri,
|
||||
title: reward.title,
|
||||
description: reward.description,
|
||||
money: {
|
||||
amount: reward.money.amount,
|
||||
currency: reward.money.currency,
|
||||
},
|
||||
isFinite: reward.isFinite,
|
||||
unitsTotal: reward.unitsTotal,
|
||||
},
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
if (rewardErr) throw rewardErr;
|
||||
});
|
||||
if (!$session) {
|
||||
showSessionErrorToast = true;
|
||||
throw new Error("User session not found");
|
||||
}
|
||||
|
||||
if (currentData.collaborations.length > 0) {
|
||||
currentData.collaborations.forEach(async (collab) => {
|
||||
const { error: collaborationErr } = await apiProjectCollaborationsPost({
|
||||
body: {
|
||||
project: projectIri,
|
||||
title: collab.title,
|
||||
description: collab.description,
|
||||
isFulfilled: false,
|
||||
},
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
if (collaborationErr) throw collaborationErr;
|
||||
});
|
||||
}
|
||||
const result = await publishDraft($currentDraft, $session, String(resolvedProject.id));
|
||||
markCurrentDraftClean(result.resources);
|
||||
} catch (err) {
|
||||
errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
if (currentData.budgetItems.minimum.length > 0) {
|
||||
currentData.budgetItems.minimum.forEach(async (item) => {
|
||||
const { error: budgetItemErr } = await apiProjectBudgetItemsPost({
|
||||
body: {
|
||||
project: projectIri,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
money: {
|
||||
amount: item.money.amount,
|
||||
currency: item.money.currency,
|
||||
},
|
||||
deadline: item.deadline,
|
||||
},
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
if (budgetItemErr) throw budgetItemErr;
|
||||
});
|
||||
}
|
||||
|
||||
if (currentData.budgetItems.optimum.length > 0) {
|
||||
currentData.budgetItems.optimum.forEach(async (item) => {
|
||||
const { error: budgetItemErr } = await apiProjectBudgetItemsPost({
|
||||
body: {
|
||||
project: projectIri,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
money: {
|
||||
amount: item.money.amount,
|
||||
currency: item.money.currency,
|
||||
},
|
||||
deadline: item.deadline,
|
||||
},
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
if (budgetItemErr) throw budgetItemErr;
|
||||
});
|
||||
}
|
||||
|
||||
const { error: projectErr } = await apiProjectsIdPatch({
|
||||
path: { id: String(project.id) },
|
||||
body: {
|
||||
title: currentData.title,
|
||||
subtitle: currentData.subtitle,
|
||||
video: currentData.campaignInfo.video,
|
||||
description:
|
||||
currentData.campaignInfo.objectives +
|
||||
currentData.campaignInfo.legacy +
|
||||
currentData.campaignInfo.targetAudience +
|
||||
currentData.campaignInfo.team,
|
||||
deadline: currentData.configuration.projectDeadline,
|
||||
},
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
|
||||
if (projectErr) throw projectErr;
|
||||
} catch (err: any) {
|
||||
errorMessage = err;
|
||||
saveState = "idle";
|
||||
} finally {
|
||||
errorMessage = "";
|
||||
saveState = "saved";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle publish
|
||||
*/
|
||||
async function handlePublish() {
|
||||
// In Phase 1, this is disabled until all steps are complete and sent to API
|
||||
clearLocalStorage();
|
||||
window.location.href =
|
||||
"/project/" + (project.slug ? project.slug : project.id) + "/publish";
|
||||
function handlePublish() {
|
||||
if (!$currentDraft || !resolvedProject) return;
|
||||
|
||||
const idOrSlug = resolvedProject.slug ?? resolvedProject.id;
|
||||
|
||||
deleteCurrentDraft($currentDraft.draftId, $currentDraft.userId);
|
||||
window.location.href = `/project/${idOrSlug}/publish`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ProjectEditorShell
|
||||
{errorMessage}
|
||||
{saveState}
|
||||
{project}
|
||||
onSave={handleSave}
|
||||
onPublish={handlePublish}
|
||||
>
|
||||
{@const StepComponent = getStepComponent(currentStep)}
|
||||
<StepComponent {project} onPublish={handlePublish} />
|
||||
</ProjectEditorShell>
|
||||
{#if isInitialized && resolvedProject}
|
||||
<ProjectEditorShell
|
||||
{errorMessage}
|
||||
project={resolvedProject}
|
||||
{showSessionErrorToast}
|
||||
onSave={saveToAPI}
|
||||
onPublish={handlePublish}
|
||||
>
|
||||
{@const StepComponent = getStepComponent(currentStep)}
|
||||
<StepComponent project={resolvedProject} />
|
||||
</ProjectEditorShell>
|
||||
{/if}
|
||||
|
||||
@@ -17,17 +17,22 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Eye from "../../../../../components/icons/Eye.svelte";
|
||||
import ActionableButton from "../../../../../components/library/ActionableButton.svelte";
|
||||
import Button from "../../../../../components/library/Button.svelte";
|
||||
import TabNavigation, {
|
||||
type Tab,
|
||||
} from "../../../../../components/library/TabNavigation.svelte";
|
||||
import Toast from "../../../../../components/library/Toast.svelte";
|
||||
import { t } from "../../../../../i18n/store";
|
||||
import {
|
||||
wizardState,
|
||||
currentDraft,
|
||||
hasUnsavedChanges,
|
||||
isReadyToPublish,
|
||||
isSavingDraft,
|
||||
navigateToStep,
|
||||
persistenceError,
|
||||
isReadyToPublish,
|
||||
} from "../../../../../stores/wizard-state";
|
||||
updateProject,
|
||||
} from "../../../../../stores/drafts/projectDraft";
|
||||
import EditIcon from "../../../../../svgs/EditIcon.svelte";
|
||||
|
||||
import type { Project } from "../../../../../openapi/client";
|
||||
@@ -36,16 +41,16 @@
|
||||
let {
|
||||
project,
|
||||
children,
|
||||
showSessionErrorToast = $bindable(false),
|
||||
onSave,
|
||||
onPublish,
|
||||
saveState = $bindable("idle"),
|
||||
errorMessage = $bindable(""),
|
||||
}: {
|
||||
project: Project;
|
||||
children: Snippet;
|
||||
onSave: () => Promise<void>;
|
||||
showSessionErrorToast?: boolean;
|
||||
onSave: () => void;
|
||||
onPublish?: () => void;
|
||||
saveState: "idle" | "saving" | "saved";
|
||||
errorMessage: string;
|
||||
} = $props();
|
||||
|
||||
@@ -60,30 +65,24 @@
|
||||
]);
|
||||
|
||||
// Reactive values from store
|
||||
const currentStep = $derived($wizardState.currentStep);
|
||||
const currentStep = $derived($currentDraft?.wizardForm.currentStep ?? 1);
|
||||
|
||||
// Reactive derived values for title and subtitle
|
||||
const title = $derived($wizardState.title);
|
||||
const subtitle = $derived($wizardState.subtitle);
|
||||
const title = $derived($currentDraft?.createProject.title);
|
||||
const subtitle = $derived($currentDraft?.createProject.subtitle);
|
||||
|
||||
/**
|
||||
* Handle title change
|
||||
*/
|
||||
function handleTitleChange(newTitle: string) {
|
||||
wizardState.update((state) => ({
|
||||
...state,
|
||||
title: newTitle,
|
||||
}));
|
||||
updateProject({ title: newTitle });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle subtitle change
|
||||
*/
|
||||
function handleSubtitleChange(newSubtitle: string) {
|
||||
wizardState.update((state) => ({
|
||||
...state,
|
||||
subtitle: newSubtitle,
|
||||
}));
|
||||
updateProject({ subtitle: newSubtitle });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,14 +95,14 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Save localStorage Draft to API button
|
||||
* Handle Save IndexedDB Draft to API button
|
||||
*/
|
||||
async function handleSave() {
|
||||
await onSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Publish button
|
||||
* Handle Publish button (redirects to publish page after saving)
|
||||
*/
|
||||
function handlePublish() {
|
||||
if (onPublish) {
|
||||
@@ -114,6 +113,12 @@
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="p-10 pb-20">
|
||||
<!-- Session Error Toast -->
|
||||
{#if showSessionErrorToast}
|
||||
<Toast variant="error" class="mb-6" bind:showToast={showSessionErrorToast}>
|
||||
{$t("wizard.errors.session.title")}
|
||||
</Toast>
|
||||
{/if}
|
||||
<!-- Storage Error Alert -->
|
||||
{#if $persistenceError}
|
||||
<div
|
||||
@@ -166,7 +171,7 @@
|
||||
|
||||
<!-- Header with title and action buttons -->
|
||||
<div
|
||||
class="bg-soft-purple border-variant1 mb-6 flex items-center justify-between gap-4 rounded-3xl border px-6 py-4 shadow-sm"
|
||||
class="bg-purple-soft border-variant1 mb-6 flex items-center justify-between gap-4 rounded-3xl border px-6 py-4 shadow-sm"
|
||||
>
|
||||
<!-- Left section: Icon + Title/Subtitle -->
|
||||
<div class="flex flex-1 items-center gap-4">
|
||||
@@ -202,21 +207,20 @@
|
||||
<Eye />
|
||||
{$t("common.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
<ActionableButton
|
||||
kind="secondary"
|
||||
size="md"
|
||||
class="disabled:pointer-events-none"
|
||||
onclick={handleSave}
|
||||
disabled={saveState === "saving" || saveState === "saved" ? true : false}
|
||||
action={handleSave}
|
||||
disabled={!$hasUnsavedChanges || $isSavingDraft}
|
||||
>
|
||||
{#if saveState === "saving"}
|
||||
{$t("common.saving")}
|
||||
{:else if saveState === "saved"}
|
||||
{$t("common.saved")}
|
||||
{:else}
|
||||
{#snippet children()}
|
||||
{$t("common.save")}
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet actionedChildren()}
|
||||
{$t("common.saved")}
|
||||
{/snippet}
|
||||
</ActionableButton>
|
||||
<Button
|
||||
class="disabled:pointer-events-none disabled:opacity-24"
|
||||
kind="primary"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { apiProjectsIdOrSlugGet, apiProjectsIdPatch } from "../../../../../opena
|
||||
import { getSession } from "../../../../../auth/session";
|
||||
import App from "../../../../../components/App.svelte";
|
||||
import ProjectEditor from "./ProjectEditor.svelte";
|
||||
import { goto } from "../../../../../utils/navigation";
|
||||
|
||||
const { t } = Astro.locals;
|
||||
const locale = Astro.locals.lang;
|
||||
@@ -15,28 +16,32 @@ if (!idOrSlug) {
|
||||
|
||||
const session = await getSession(Astro.cookies);
|
||||
if (!session) {
|
||||
return Astro.redirect("/");
|
||||
const requestUrl = new URL(Astro.request.url);
|
||||
const callbackURL = requestUrl.pathname + requestUrl.search;
|
||||
return goto("/login", { query: { callback: callbackURL } });
|
||||
}
|
||||
|
||||
const { data: project } = await apiProjectsIdOrSlugGet({ path: { idOrSlug } });
|
||||
if (!project) {
|
||||
return Astro.redirect("/");
|
||||
}
|
||||
|
||||
const { error, request } = await apiProjectsIdPatch({
|
||||
path: { id: String(project.id!) },
|
||||
body: { id: project.id },
|
||||
const { data: project } = await apiProjectsIdOrSlugGet({
|
||||
path: { idOrSlug },
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error({ error, request });
|
||||
return Astro.redirect("/");
|
||||
if (project?.id) {
|
||||
const { error, request } = await apiProjectsIdPatch({
|
||||
path: { id: String(project.id) },
|
||||
body: { id: project.id },
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error({ error, request });
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title={t("pages.project.edit.title", { project: project.title })}>
|
||||
<Layout title={t("pages.project.edit.title", { project: project?.title ?? "" })}>
|
||||
<App locale={locale} session={session} client:load>
|
||||
<ProjectEditor project={project} {session} client:load />
|
||||
<ProjectEditor idOrSlug={idOrSlug} project={project} client:load />
|
||||
</App>
|
||||
</Layout>
|
||||
|
||||
@@ -14,16 +14,16 @@ const initialProjects: Project[] = [];
|
||||
// Parse URL search parameters for SSR
|
||||
const url = new URL(Astro.request.url);
|
||||
const initialFilters = {
|
||||
query: url.searchParams.get("q") || "",
|
||||
statusFilter: url.searchParams.get("status") || "",
|
||||
categories: url.searchParams.get("categories")?.split(",").filter(Boolean) || [],
|
||||
title: url.searchParams.get("title") || "",
|
||||
status: url.searchParams.get("status") || "",
|
||||
"categories[]": url.searchParams.getAll("categories[]") || [],
|
||||
};
|
||||
|
||||
// Check if URL has search parameters
|
||||
const hasInitialSearch = !!(
|
||||
initialFilters.query ||
|
||||
initialFilters.statusFilter ||
|
||||
initialFilters.categories.length > 0
|
||||
initialFilters.title ||
|
||||
initialFilters.status ||
|
||||
initialFilters["categories[]"].length > 0
|
||||
);
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import CampaignCard from "../../../../components/home/CampaignCard.svelte";
|
||||
import DefaultAvatar from "../../../../components/icons/DefaultAvatar.svelte";
|
||||
import Grid from "../../../../components/library/Grid.svelte";
|
||||
import ShareButton from "../../../../components/library/Share/ShareButton.svelte";
|
||||
import ProfileDonorType from "../../../../components/profile/ProfileDonorType.svelte";
|
||||
import ProfileInfo from "../../../../components/profile/ProfileInfo.svelte";
|
||||
import Tabs from "../../../../components/Tabs.svelte";
|
||||
import { t } from "../../../../i18n/store";
|
||||
import VerifiedBadge from "../../../../svgs/VerifiedBadge.svelte";
|
||||
import { getDefaultCurrency } from "../../../../utils/consts";
|
||||
import { renderMarkdown } from "../../../../utils/renderMarkdown";
|
||||
|
||||
import type { User } from "../../../../openapi/client/types.gen";
|
||||
import type { Campaign } from "../../../../types/campaign";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
campaigns?: Campaign[];
|
||||
projectsDonated?: number;
|
||||
moneyDonatedAmount?: number;
|
||||
moneyDonatedCurrency?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
user,
|
||||
campaigns = [],
|
||||
projectsDonated = 0,
|
||||
moneyDonatedAmount = 0,
|
||||
moneyDonatedCurrency = getDefaultCurrency(),
|
||||
}: Props = $props();
|
||||
|
||||
const displayName = $derived(user.displayName ?? user.handle);
|
||||
|
||||
let pageUrl = $state("");
|
||||
$effect(() => {
|
||||
pageUrl = window.location.href;
|
||||
});
|
||||
|
||||
const tabs = $derived([
|
||||
{ id: "about", label: $t("profile.tabs.about") },
|
||||
{ id: "projects", label: $t("profile.tabs.projects") },
|
||||
{ id: "donorType", label: $t("profile.tabs.donorType") },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<!-- Hero: cover + avatar + action buttons -->
|
||||
<div class="bg-secondary relative h-100 w-full">
|
||||
<div class="absolute right-6 bottom-6 flex gap-3">
|
||||
<ShareButton
|
||||
variant="profile"
|
||||
url={pageUrl}
|
||||
buttonClass="bg-variant1 text-secondary rounded-3xl px-6 py-4"
|
||||
/>
|
||||
<!--
|
||||
<Button kind="primary" size="sm">
|
||||
{$t("profile.subscribe")}
|
||||
</Button>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<!-- Avatar overlapping cover bottom -->
|
||||
<div
|
||||
class="absolute top-full left-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center"
|
||||
>
|
||||
<div class="relative">
|
||||
{#if user.avatar}
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={displayName}
|
||||
class="size-35 rounded-2xl object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<DefaultAvatar class="size-35 rounded-2xl" />
|
||||
{/if}
|
||||
<div class="absolute top-full left-1/2 size-12 -translate-x-1/2 -translate-y-1/2">
|
||||
<VerifiedBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name, location, social links -->
|
||||
<ProfileInfo {displayName} links={user.links} email={user.email} territory={user.territory} />
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-8 w-full" style="--color-tertiary: var(--color-content)">
|
||||
<Tabs {tabs} />
|
||||
</div>
|
||||
|
||||
<!-- Tab: About the profile -->
|
||||
<div data-tab-content="about" class="mx-auto mt-10 min-h-10 w-full max-w-3xl px-4 text-center">
|
||||
{#if user.description}
|
||||
{#await renderMarkdown(user.description) then html}
|
||||
<div class="prose prose-base text-content leading-6">
|
||||
{@html html}
|
||||
</div>
|
||||
{/await}
|
||||
{:else}
|
||||
<p class="text-content text-base leading-relaxed">
|
||||
{$t("profile.noBio")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab: Supported projects -->
|
||||
<div
|
||||
data-tab-content="projects"
|
||||
class="mx-auto mt-10 min-h-10 w-full max-w-5xl px-4"
|
||||
style="display:none"
|
||||
>
|
||||
{#if campaigns.length > 0}
|
||||
<Grid class="grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{#each campaigns as campaign}
|
||||
<CampaignCard size="small" {campaign} />
|
||||
{/each}
|
||||
</Grid>
|
||||
{:else}
|
||||
<p class="text-content text-center text-base">{$t("profile.noProjects")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab: Donor type -->
|
||||
<div data-tab-content="donorType" class="w-full" style="display:none">
|
||||
<ProfileDonorType {projectsDonated} {moneyDonatedAmount} {moneyDonatedCurrency} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
import User from "./User.svelte";
|
||||
import Layout from "../../../../layouts/Layout.astro";
|
||||
import App from "../../../../components/App.svelte";
|
||||
import {
|
||||
apiUsersIdOrHandleGet,
|
||||
apiProjectSupportsGetCollection,
|
||||
apiProjectSupportsmoneyTotalGetCollection,
|
||||
apiProjectsGetCollection,
|
||||
} from "../../../../openapi/client";
|
||||
import type { Money, ProjectSupport } from "../../../../openapi/client";
|
||||
import { apiProjectSupportsGetCollectionUrl } from "../../../../openapi/client/paths.gen";
|
||||
import type { Campaign } from "../../../../types/campaign";
|
||||
import { client } from "../../../../openapi/client/client.gen";
|
||||
|
||||
const { idOrHandle } = Astro.params;
|
||||
const { lang, session } = Astro.locals;
|
||||
|
||||
if (!idOrHandle) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const { data: user, error } = await apiUsersIdOrHandleGet({
|
||||
path: { idOrHandle },
|
||||
});
|
||||
|
||||
if (error || !user) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
if (!user.accounting) {
|
||||
throw new Error(`User ${idOrHandle} has no Accounting`);
|
||||
}
|
||||
|
||||
type ProjectSupportsCollection = { member: ProjectSupport[]; totalItems: number };
|
||||
|
||||
let userSupports: ProjectSupport[] = [];
|
||||
let totalDonated = { amount: 0, currency: import.meta.env.PUBLIC_DEFAULT_CURRENCY as string };
|
||||
let projectsDonatedCount = 0;
|
||||
|
||||
try {
|
||||
const [supportsResponse, supportsTotal] = await Promise.all([
|
||||
apiProjectSupportsGetCollection({ query: { origin: user.accounting } }),
|
||||
client.get({
|
||||
url: client.buildUrl({
|
||||
url: apiProjectSupportsGetCollectionUrl,
|
||||
query: { origin: user.accounting, itemsPerPage: 1 },
|
||||
}),
|
||||
headers: { Accept: "application/ld+json" },
|
||||
}) as unknown as Promise<{ data: ProjectSupportsCollection }>,
|
||||
]);
|
||||
|
||||
userSupports = supportsResponse.data ?? [];
|
||||
projectsDonatedCount = supportsTotal.data?.totalItems ?? userSupports.length;
|
||||
|
||||
if (userSupports.length > 0) {
|
||||
const { data: moneyTotal } = await apiProjectSupportsmoneyTotalGetCollection({
|
||||
query: { origin: user.accounting },
|
||||
});
|
||||
|
||||
if (moneyTotal?.amount && moneyTotal?.currency) {
|
||||
totalDonated = {
|
||||
amount: Number(moneyTotal.amount),
|
||||
currency: moneyTotal.currency,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error fetching user supports:", e);
|
||||
}
|
||||
|
||||
const slugs = userSupports
|
||||
.slice(0, 10)
|
||||
.map((s) => s.project?.split("/").at(-1))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
let campaigns: Campaign[] = [];
|
||||
|
||||
if (slugs.length > 0) {
|
||||
try {
|
||||
const { data: projects } = await apiProjectsGetCollection({
|
||||
query: { "slug[]": slugs },
|
||||
headers: { "Accept-Language": lang },
|
||||
});
|
||||
|
||||
if (projects) {
|
||||
campaigns = projects
|
||||
.map((project) => ({
|
||||
...project,
|
||||
slug: project.slug!,
|
||||
title: project.title!,
|
||||
image: project.video?.thumbnail ?? "",
|
||||
minimum: project.budget?.minimum?.money as Money,
|
||||
optimum: project.budget?.optimum?.money as Money | undefined,
|
||||
size: "small",
|
||||
}))
|
||||
.filter((c) => c.slug && c.title) as Campaign[];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error fetching projects:", e);
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title={user.displayName ?? user.handle} description={user.handle}>
|
||||
<App client:load locale={lang} session={session} class="mt-0 pb-20">
|
||||
<User
|
||||
{user}
|
||||
{campaigns}
|
||||
projectsDonated={projectsDonatedCount}
|
||||
moneyDonatedAmount={totalDonated.amount}
|
||||
moneyDonatedCurrency={totalDonated.currency}
|
||||
client:load
|
||||
/>
|
||||
</App>
|
||||
</Layout>
|
||||
@@ -1,7 +1,19 @@
|
||||
import { getBaseUrl } from "../../../utils/consts";
|
||||
import { getSession } from "../../../auth/session";
|
||||
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
// Whitelist of safe response headers to forward to the client
|
||||
const SAFE_RESPONSE_HEADERS = [
|
||||
"content-type",
|
||||
"content-language",
|
||||
"content-length",
|
||||
"cache-control",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"expires",
|
||||
"vary",
|
||||
];
|
||||
|
||||
/**
|
||||
* API Relay Endpoint
|
||||
*
|
||||
@@ -14,94 +26,53 @@ import type { APIRoute } from "astro";
|
||||
*
|
||||
* Usage from client:
|
||||
* ```ts
|
||||
* const response = await fetch('/api/relay/v4/users/123', {
|
||||
* method: 'GET',
|
||||
* headers: { 'Accept-Language': 'en' }
|
||||
* });
|
||||
* const { data, err } = await apiProjectsPost({ baseUrl: '/api/relay', ... });
|
||||
* const response = await fetch('/api/relay/v4/users/123', ...;
|
||||
* ```
|
||||
*/
|
||||
export const ALL: APIRoute = async ({ request, cookies, params }) => {
|
||||
// Extract the API path from the catch-all parameter
|
||||
const apiPath = params.path || "";
|
||||
|
||||
// Get the access token from cookies
|
||||
const accessToken = cookies.get("access-token")?.value;
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized", message: "No access token found" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the token to get the actual bearer token
|
||||
const tokenData = JSON.parse(accessToken);
|
||||
const bearerToken = tokenData.token || accessToken;
|
||||
const query = new URL(request.url).search;
|
||||
const url = `${import.meta.env.PUBLIC_API_URL}/${params.path}${query}`;
|
||||
|
||||
// Build the target URL
|
||||
const url = new URL(request.url);
|
||||
const targetUrl = `${getBaseUrl()}/${apiPath}${url.search}`;
|
||||
const reqHeaders = new Headers(request.headers);
|
||||
reqHeaders.delete("host");
|
||||
|
||||
// Prepare headers
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("Authorization", `Bearer ${bearerToken}`);
|
||||
|
||||
// Remove host header to avoid conflicts
|
||||
headers.delete("host");
|
||||
|
||||
// Prepare request body for methods that support it
|
||||
let body: string | null = null;
|
||||
if (request.method === "POST" || request.method === "PUT" || request.method === "PATCH") {
|
||||
body = await request.text();
|
||||
const session = await getSession(cookies);
|
||||
if (session && session.token.asHttpHeaders) {
|
||||
for (const [key, value] of Object.entries(session.token.asHttpHeaders)) {
|
||||
reqHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Forward the request to the actual API
|
||||
const response = await fetch(targetUrl, {
|
||||
let reqBody: string | null = null;
|
||||
if (request.method === "POST" || request.method === "PUT" || request.method === "PATCH") {
|
||||
reqBody = await request.text();
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
headers: reqHeaders,
|
||||
body: reqBody,
|
||||
});
|
||||
|
||||
// Get the response body
|
||||
const responseText = await response.text();
|
||||
const resBody = await response.text();
|
||||
|
||||
// Whitelist of safe response headers to forward to the client
|
||||
// These headers are important for caching, content negotiation, and proper HTTP semantics
|
||||
const SAFE_RESPONSE_HEADERS = [
|
||||
"content-type",
|
||||
"content-language",
|
||||
"content-length",
|
||||
"cache-control",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"expires",
|
||||
"vary",
|
||||
"link", // For pagination
|
||||
"x-total-count", // Common in REST APIs for total item count
|
||||
];
|
||||
|
||||
// Build response headers by copying whitelisted headers from the API response
|
||||
const responseHeaders = new Headers();
|
||||
const resHeaders = new Headers();
|
||||
response.headers.forEach((value, key) => {
|
||||
if (SAFE_RESPONSE_HEADERS.includes(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value);
|
||||
resHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure Content-Type is always set (fallback to application/json)
|
||||
if (!responseHeaders.has("content-type")) {
|
||||
responseHeaders.set("content-type", "application/json");
|
||||
if (!resHeaders.has("content-type")) {
|
||||
resHeaders.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
// Return the response back to the client
|
||||
return new Response(responseText, {
|
||||
return new Response(resBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
headers: resHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("API Relay Error:", error);
|
||||
|
||||
@@ -28,14 +28,7 @@ export class ProjectsService {
|
||||
try {
|
||||
const response = await apiProjectsGetCollection({
|
||||
query: {
|
||||
// Text search
|
||||
...(filters.query?.trim() && { title: filters.query.trim() }),
|
||||
// Categories (array filter for OR logic) - Fixed: use "categories[]" not "category[]"
|
||||
...(filters.categories?.length && { "categories[]": filters.categories }),
|
||||
// Status filter
|
||||
...(filters.statusFilter &&
|
||||
filters.statusFilter !== "all" && { status: filters.statusFilter }),
|
||||
// Pagination
|
||||
...filters,
|
||||
page: options?.page || 1,
|
||||
itemsPerPage: options?.limit || 20,
|
||||
},
|
||||
|
||||
@@ -17,24 +17,24 @@ export const sortOptions: SortOption[] = [
|
||||
key: "date-desc",
|
||||
field: "dateCreated",
|
||||
direction: "desc",
|
||||
label: "contributions.filters.order.options.date-desc",
|
||||
label: "pages.admin.charges.filters.order.options.date-desc",
|
||||
},
|
||||
{
|
||||
key: "date-asc",
|
||||
field: "dateCreated",
|
||||
direction: "asc",
|
||||
label: "contributions.filters.order.options.date-asc",
|
||||
label: "pages.admin.charges.filters.order.options.date-asc",
|
||||
},
|
||||
{
|
||||
key: "amount-desc",
|
||||
field: "money.amount",
|
||||
direction: "desc",
|
||||
label: "contributions.filters.order.options.amount-desc",
|
||||
label: "pages.admin.charges.filters.order.options.amount-desc",
|
||||
},
|
||||
{
|
||||
key: "amount-asc",
|
||||
field: "money.amount",
|
||||
direction: "asc",
|
||||
label: "contributions.filters.order.options.amount-asc",
|
||||
label: "pages.admin.charges.filters.order.options.amount-asc",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import murmur from "murmurhash-js";
|
||||
import z from "zod";
|
||||
|
||||
import { type Draft, type Wizard } from "./projectDraft";
|
||||
import { projectCreationSchema } from "../../pages/[...locale]/create/validation";
|
||||
|
||||
import type {
|
||||
ProjectBudgetItem,
|
||||
ProjectCollaboration,
|
||||
ProjectProjectCreationDto,
|
||||
ProjectReward,
|
||||
} from "../../openapi/client";
|
||||
|
||||
export type ValidationErrors = Record<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;
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
if (typeof document === "undefined") {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation schema for Configuration step
|
||||
*/
|
||||
export const configurationSchema = z.object({
|
||||
projectDeadline: z.enum(["minimum", "optimum"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate Configuration step (Step 1)
|
||||
*
|
||||
* Validates against the configurationSchema using Zod.
|
||||
* Updates validationErrors store with any validation failures.
|
||||
*
|
||||
* @returns Record of field errors (empty if valid)
|
||||
*/
|
||||
export function validateConfiguration(wizard: Wizard): ValidationErrors {
|
||||
const result = configurationSchema.safeParse(wizard.configuration);
|
||||
|
||||
if (!result.success) {
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
result.error.issues.forEach((issue) => {
|
||||
const fieldName = issue.path[0] as string;
|
||||
|
||||
if (!errors[fieldName]) {
|
||||
errors[fieldName] = issue.message;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Campaign Info step (Step 2)
|
||||
*
|
||||
* Validates all campaign info fields according to requirements:
|
||||
* - At least 1 image OR 1 video required
|
||||
* - Objectives: min 50, max 5000 characters (plain text)
|
||||
* - Legacy: min 50, max 5000 characters
|
||||
* - Target audience: min 30, max 5000 characters
|
||||
* - Team: min 50, max 5000 characters
|
||||
*
|
||||
* @returns Record of field errors (empty if valid)
|
||||
*
|
||||
* @example
|
||||
* // Validate before completing step
|
||||
* function handleContinue() {
|
||||
* const errors = validateCampaignInfo();
|
||||
* if (Object.keys(errors).length === 0) {
|
||||
* completeCurrentStep();
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export function validateCampaignInfo(wizard: Wizard): ValidationErrors {
|
||||
const data = wizard.campaignInfo;
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
// Media validation
|
||||
if (data.images.length === 0 && !data.video) {
|
||||
errors.media = "pages.project.edit.rewards.validation_info.reward.media.required";
|
||||
}
|
||||
|
||||
// Objectives validation
|
||||
const objectivesPlainText = stripHtml(data.objectives).trim();
|
||||
if (objectivesPlainText.length === 0) {
|
||||
errors.objectives = "pages.project.edit.rewards.validation_info.reward.objectives.required";
|
||||
} else if (objectivesPlainText.length < 50) {
|
||||
errors.objectives =
|
||||
"pages.project.edit.rewards.validation_info.reward.objectives.min_length";
|
||||
} else if (objectivesPlainText.length > 5000) {
|
||||
errors.objectives =
|
||||
"pages.project.edit.rewards.validation_info.reward.objectives.max_length";
|
||||
}
|
||||
|
||||
// Legacy validation
|
||||
const legacyPlainText = stripHtml(data.legacy).trim();
|
||||
if (legacyPlainText.length === 0) {
|
||||
errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.required";
|
||||
} else if (legacyPlainText.length < 50) {
|
||||
errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.min_length";
|
||||
} else if (legacyPlainText.length > 5000) {
|
||||
errors.legacy = "pages.project.edit.rewards.validation_info.reward.legacy.max_length";
|
||||
}
|
||||
|
||||
// Target audience validation
|
||||
const targetPlainText = stripHtml(data.targetAudience).trim();
|
||||
if (targetPlainText.length === 0) {
|
||||
errors.targetAudience = "pages.project.edit.rewards.validation_info.reward.target.required";
|
||||
} else if (targetPlainText.length < 30) {
|
||||
errors.targetAudience =
|
||||
"pages.project.edit.rewards.validation_info.reward.target.min_length";
|
||||
} else if (targetPlainText.length > 5000) {
|
||||
errors.targetAudience =
|
||||
"pages.project.edit.rewards.validation_info.reward.target.max_length";
|
||||
}
|
||||
|
||||
// Team validation
|
||||
const teamPlainText = stripHtml(data.team).trim();
|
||||
if (teamPlainText.length === 0) {
|
||||
errors.team = "pages.project.edit.rewards.validation_info.reward.team.required";
|
||||
} else if (teamPlainText.length < 50) {
|
||||
errors.team = "pages.project.edit.rewards.validation_info.reward.team.min_length";
|
||||
} else if (teamPlainText.length > 5000) {
|
||||
errors.team = "pages.project.edit.rewards.validation_info.reward.team.max_length";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateReward(reward: ProjectReward): ValidationErrors {
|
||||
const errors: ValidationErrors = {};
|
||||
const hash = murmur.murmur3(JSON.stringify(reward));
|
||||
|
||||
if (!reward.title.trim()) {
|
||||
errors[`reward_error_title_${hash}`] = "pages.project.edit.rewards.validation.title";
|
||||
}
|
||||
|
||||
if (!reward.money.amount || reward.money.amount <= 0) {
|
||||
errors[`reward_error_amount_${hash}`] = "pages.project.edit.rewards.validation.amount";
|
||||
}
|
||||
|
||||
if (reward.isFinite && (!reward.unitsTotal || reward.unitsTotal <= 0)) {
|
||||
errors[`reward_error_units_${hash}`] = "pages.project.edit.rewards.validation.units";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateCollaboration(collab: ProjectCollaboration): ValidationErrors {
|
||||
const errors: ValidationErrors = {};
|
||||
const hash = murmur.murmur3(JSON.stringify(collab));
|
||||
|
||||
if (!collab.title.trim()) {
|
||||
errors[`collab_error_title_${hash}`] = "pages.project.edit.collaborations.validation.title";
|
||||
}
|
||||
|
||||
if (collab.description && collab.description.length > 1000) {
|
||||
errors[`collab_error_description_too_long_${hash}`] =
|
||||
"pages.project.edit.collaborations.validation.descriptionTooLong";
|
||||
}
|
||||
|
||||
if (!collab.description.trim()) {
|
||||
errors[`collab_error_description_${hash}`] =
|
||||
"pages.project.edit.collaborations.validation.description";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateBudgetItem(item: ProjectBudgetItem): ValidationErrors {
|
||||
const errors: ValidationErrors = {};
|
||||
const hash = murmur.murmur3(JSON.stringify(item));
|
||||
|
||||
if (!item.title.trim()) {
|
||||
errors[`budget_error_title_${hash}`] = "pages.project.edit.budget.validation.title.";
|
||||
}
|
||||
|
||||
if (!item.description.trim()) {
|
||||
errors[`budget_error_description_${hash}`] =
|
||||
"pages.project.edit.budget.validation.description";
|
||||
}
|
||||
|
||||
if (!item.money.amount || item.money.amount <= 0) {
|
||||
errors[`budget_error_amount_${hash}`] = "pages.project.edit.budget.validation.amount";
|
||||
}
|
||||
|
||||
if (!item.money.currency) {
|
||||
errors[`budget_error_currency_${hash}`] = "pages.project.edit.budget.validation.currency";
|
||||
}
|
||||
|
||||
if (!item.type) {
|
||||
errors[`budget_error_type_${hash}`] = "pages.project.edit.budget.validation.type";
|
||||
}
|
||||
|
||||
if (!item.deadline || (item.deadline !== "minimum" && item.deadline !== "optimum")) {
|
||||
errors[`budget_error_deadline_${hash}`] = "pages.project.edit.budget.validation.class";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateBudgetAmount(draft: Draft) {
|
||||
const budgetItems = draft.wizardForm.budgetItems;
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
if (budgetItems.minimum.length <= 0) {
|
||||
errors.minimum_length = "pages.project.edit.budget.validation.minimumItemsLength";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single field and updates the validation errors store.
|
||||
* Uses Zod's type system with proper type guards to avoid unsafe type assertions.
|
||||
* @param fieldName - The name of the field to validate
|
||||
* @param value - The current value of the field
|
||||
*/
|
||||
export function validateField(
|
||||
fieldName: keyof ProjectProjectCreationDto,
|
||||
value: unknown,
|
||||
): string | null {
|
||||
type SchemaFields = keyof typeof projectCreationSchema.shape;
|
||||
|
||||
if (!(fieldName in projectCreationSchema.shape)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldSchema = projectCreationSchema.shape[fieldName as SchemaFields];
|
||||
|
||||
const result = (fieldSchema as z.ZodTypeAny).safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
return result.error.issues[0]?.message || "";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
import { liveQuery } from "dexie";
|
||||
import { writable, readable, get, derived } from "svelte/store";
|
||||
|
||||
import {
|
||||
validateBudgetItem,
|
||||
validateCollaboration,
|
||||
validateDraftToPublish,
|
||||
validateReward,
|
||||
type ValidationErrors,
|
||||
} from "./draftValidation";
|
||||
import { session } from "../../auth/store";
|
||||
import { db } from "../../utils/drafts/db";
|
||||
import { draftRepo } from "../../utils/drafts/repository";
|
||||
|
||||
import type {
|
||||
Budget,
|
||||
ProjectBudgetItem,
|
||||
ProjectCollaboration,
|
||||
ProjectProjectCreationDto,
|
||||
ProjectReward,
|
||||
} from "../../openapi/client";
|
||||
|
||||
/**
|
||||
* Media image data
|
||||
*/
|
||||
export interface MediaImage {
|
||||
id: string;
|
||||
url: string; // Base64 data URL or API URL
|
||||
file?: File; // Original file reference
|
||||
size: number; // File size in bytes
|
||||
name: string; // Original filename
|
||||
}
|
||||
|
||||
export interface WizardConfiguration {
|
||||
projectDeadline: "minimum" | "optimum"; // Default: minimum
|
||||
}
|
||||
|
||||
export interface WizardCampaignInfo {
|
||||
// Media/**
|
||||
images: MediaImage[];
|
||||
video: string | undefined;
|
||||
|
||||
// Rich text content (stored as HTML)
|
||||
objectives: string;
|
||||
legacy: string;
|
||||
targetAudience: string;
|
||||
team: string;
|
||||
}
|
||||
|
||||
export type Wizard = {
|
||||
// Step navigation
|
||||
currentStep: number;
|
||||
|
||||
// Total budget amount
|
||||
budget?: Budget;
|
||||
|
||||
// Step 1: Configuration
|
||||
configuration: WizardConfiguration;
|
||||
|
||||
// Step 2: Campaign Information
|
||||
campaignInfo: WizardCampaignInfo;
|
||||
|
||||
// Step 3: Rewards
|
||||
rewards: ProjectReward[];
|
||||
|
||||
// Step 4: Collaborations
|
||||
collaborations: ProjectCollaboration[];
|
||||
|
||||
// Step 5: Budget
|
||||
budgetItems: {
|
||||
minimum: ProjectBudgetItem[];
|
||||
optimum: ProjectBudgetItem[];
|
||||
};
|
||||
|
||||
// Pending future step (Phase 6) - placeholders
|
||||
// aboutYou: WizardAboutYou;
|
||||
};
|
||||
|
||||
export interface CreateProjectForm extends ProjectProjectCreationDto {
|
||||
budget?: Budget;
|
||||
}
|
||||
|
||||
export interface ProjectDraftResources {
|
||||
rewards: ProjectReward[];
|
||||
collaborations: ProjectCollaboration[];
|
||||
budgetItems: {
|
||||
minimum: ProjectBudgetItem[];
|
||||
optimum: ProjectBudgetItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
draftId: string;
|
||||
userId: number;
|
||||
isDirty?: boolean;
|
||||
apiSnapshot?: ProjectDraftResources;
|
||||
|
||||
createProject: CreateProjectForm;
|
||||
wizardForm: Wizard;
|
||||
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export const drafts = derived(session, ($session, set) => {
|
||||
if (!$session?.user?.id) {
|
||||
set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = setDraftsStore($session.user.id);
|
||||
|
||||
const unsubscribe = store.subscribe(set);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
export const currentDraft = writable<Draft | null>(null);
|
||||
|
||||
export const wizard = derived(
|
||||
currentDraft,
|
||||
($d) =>
|
||||
$d?.wizardForm ??
|
||||
({
|
||||
currentStep: 1,
|
||||
configuration: {
|
||||
projectDeadline: "minimum",
|
||||
},
|
||||
campaignInfo: {
|
||||
images: [],
|
||||
video: "",
|
||||
objectives: "",
|
||||
legacy: "",
|
||||
targetAudience: "",
|
||||
team: "",
|
||||
},
|
||||
rewards: [],
|
||||
collaborations: [],
|
||||
budgetItems: {
|
||||
minimum: [],
|
||||
optimum: [],
|
||||
},
|
||||
} as Wizard),
|
||||
);
|
||||
export const project = derived(
|
||||
currentDraft,
|
||||
($d) =>
|
||||
$d?.createProject ?? {
|
||||
title: "",
|
||||
subtitle: "",
|
||||
categories: [],
|
||||
release: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Touched fields tracker
|
||||
*/
|
||||
export const touchedFields = writable<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Store to track if there are unsaved changes in the current draft to send it to API
|
||||
* Used to enable/disable the Save button in wizard form
|
||||
*/
|
||||
export const hasUnsavedChanges = writable<boolean>(false);
|
||||
|
||||
/**
|
||||
* Store to track if the draft is currently being saved to IndexedDB (persistence in progress)
|
||||
*/
|
||||
export const isSavingDraft = writable(false);
|
||||
|
||||
/**
|
||||
* Persistence error state
|
||||
* Tracks localStorage save failures (quota exceeded, general errors)
|
||||
*/
|
||||
export const persistenceError = writable<string | null>(null);
|
||||
|
||||
/**
|
||||
* Validation errors store
|
||||
* Stores field-specific validation error messages for the current step
|
||||
* Updated on each field change and used to provide real-time validation feedback in the UI
|
||||
*/
|
||||
export const validationErrors = writable<ValidationErrors>({});
|
||||
|
||||
/**
|
||||
* Derived store that validates the draft for publishing.
|
||||
* Returns an object with validation errors for each field, or an empty object if valid.
|
||||
*/
|
||||
export const publishErrors = derived(currentDraft, ($draft) => {
|
||||
if (!$draft) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const errors = validateDraftToPublish(get(currentDraft) as Draft);
|
||||
return errors;
|
||||
});
|
||||
|
||||
/**
|
||||
* Define whether the project is ready to publish (all steps completed and valid).
|
||||
* Used to enable/disable the Publish button in the wizard UI
|
||||
*/
|
||||
export const isReadyToPublish = derived([publishErrors, currentDraft], ([$errors, $draft]) => {
|
||||
if (!$draft) return false;
|
||||
|
||||
return Object.keys($errors).length === 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Derived store that indicates if the form is valid.
|
||||
* Returns true only when:
|
||||
* 1. All required fields have values
|
||||
* 2. There are no validation errors
|
||||
*/
|
||||
export const isCreateFormValid = derived([currentDraft, validationErrors], ([$draft, $errors]) => {
|
||||
if (Object.keys($errors).length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasTitle = ($draft?.createProject?.title?.trim().length ?? 0) > 0;
|
||||
|
||||
const hasSubtitle = ($draft?.createProject?.subtitle?.trim().length ?? 0) > 0;
|
||||
|
||||
const hasCategories = ($draft?.createProject?.categories?.length ?? 0) > 0;
|
||||
|
||||
return hasTitle && hasSubtitle && hasCategories;
|
||||
});
|
||||
|
||||
export function createDraftId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function getUserId(): number {
|
||||
const s = get(session);
|
||||
|
||||
if (!s?.user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
return s.user.id;
|
||||
}
|
||||
|
||||
export function setDraftsStore(userId: number) {
|
||||
return readable<Draft[]>([], (set) => {
|
||||
const subscription = liveQuery(() =>
|
||||
db.drafts.where("userId").equals(userId).reverse().sortBy("updatedAt"),
|
||||
).subscribe({
|
||||
next: set,
|
||||
error: console.error,
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDraft(
|
||||
project?: ProjectProjectCreationDto,
|
||||
draftId = createDraftId(),
|
||||
isDirty = true,
|
||||
resources?: ProjectDraftResources,
|
||||
) {
|
||||
const userId = getUserId();
|
||||
|
||||
const draft: Draft = {
|
||||
draftId,
|
||||
userId,
|
||||
isDirty,
|
||||
apiSnapshot: resources,
|
||||
createProject: project ?? ({} as Partial<CreateProjectForm> as CreateProjectForm),
|
||||
wizardForm: {
|
||||
currentStep: 1,
|
||||
configuration: {
|
||||
projectDeadline: "minimum",
|
||||
},
|
||||
campaignInfo: {
|
||||
images: [],
|
||||
video: "",
|
||||
objectives: "",
|
||||
legacy: "",
|
||||
targetAudience: "",
|
||||
team: "",
|
||||
},
|
||||
rewards: resources?.rewards ?? [],
|
||||
collaborations: resources?.collaborations ?? [],
|
||||
budgetItems: resources?.budgetItems ?? {
|
||||
minimum: [],
|
||||
optimum: [],
|
||||
},
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
draft.createProject.categories = Object.values(project?.categories || []);
|
||||
|
||||
await draftRepo.create(draft);
|
||||
currentDraft.set(draft);
|
||||
|
||||
return draftId;
|
||||
}
|
||||
|
||||
export async function loadDraft(draftId: string) {
|
||||
const draft = await draftRepo.get(draftId);
|
||||
|
||||
if (!draft) return false;
|
||||
|
||||
currentDraft.set(draft);
|
||||
hasUnsavedChanges.set(draft.isDirty ?? true);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function initializeProjectDraft(
|
||||
project: ProjectProjectCreationDto,
|
||||
draftId: string,
|
||||
resources?: ProjectDraftResources,
|
||||
) {
|
||||
const loadedDraft = await draftRepo.get(draftId);
|
||||
|
||||
if (loadedDraft) {
|
||||
const isDirty = loadedDraft.isDirty ?? true;
|
||||
const nextDraft: Draft = {
|
||||
...loadedDraft,
|
||||
apiSnapshot: resources ?? loadedDraft.apiSnapshot,
|
||||
createProject: isDirty ? loadedDraft.createProject : project,
|
||||
wizardForm:
|
||||
!isDirty && resources
|
||||
? {
|
||||
...loadedDraft.wizardForm,
|
||||
rewards: resources.rewards,
|
||||
collaborations: resources.collaborations,
|
||||
budgetItems: resources.budgetItems,
|
||||
}
|
||||
: loadedDraft.wizardForm,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
currentDraft.set(nextDraft);
|
||||
hasUnsavedChanges.set(isDirty);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
await createDraft(project, draftId, false, resources);
|
||||
hasUnsavedChanges.set(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a field as touched (for validation UX)
|
||||
*
|
||||
* Used to track which fields the user has interacted with,
|
||||
* so validation errors only show after user touches the field.
|
||||
*
|
||||
* @param fieldName - The field identifier to mark as touched
|
||||
*
|
||||
* @example
|
||||
* // Mark field as touched on blur
|
||||
* <input on:blur={() => markFieldAsTouched('languages')} />
|
||||
*/
|
||||
export function markFieldAsTouched(fieldName: string) {
|
||||
touchedFields.update((fields) => {
|
||||
const newFields = new Set(fields);
|
||||
newFields.add(fieldName);
|
||||
return newFields;
|
||||
});
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function persistDraft() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
|
||||
isSavingDraft.set(true);
|
||||
saveTimer = setTimeout(() => {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
try {
|
||||
const updatedDraft = {
|
||||
...draft,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
currentDraft.set(updatedDraft);
|
||||
|
||||
draftRepo.update(draft.draftId, draft.userId, updatedDraft);
|
||||
|
||||
isSavingDraft.set(false);
|
||||
persistenceError.set(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to persist draft:", error);
|
||||
|
||||
// Handle QuotaExceededError specifically
|
||||
if (error instanceof DOMException && error.name === "QuotaExceededError") {
|
||||
persistenceError.set("storage_quota_exceeded");
|
||||
} else {
|
||||
persistenceError.set("storage_general_error");
|
||||
}
|
||||
|
||||
// Keep isSavingDraft as true when persistence fails
|
||||
isSavingDraft.set(true);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Wizard Form data
|
||||
*
|
||||
* Merges new wizard updates with already existing wizard data.
|
||||
*
|
||||
* @param data - Partial object with wizard data that has been modified
|
||||
*/
|
||||
export function updateWizard(data: Partial<Wizard>, options: { markUnsaved?: boolean } = {}) {
|
||||
const markUnsaved = options.markUnsaved ?? true;
|
||||
|
||||
currentDraft.update((draft) => {
|
||||
if (!draft) return draft;
|
||||
|
||||
const updated = {
|
||||
...draft,
|
||||
isDirty: markUnsaved ? true : draft.isDirty,
|
||||
wizardForm: {
|
||||
...draft.wizardForm,
|
||||
|
||||
...data,
|
||||
|
||||
configuration: {
|
||||
...draft.wizardForm.configuration,
|
||||
...data.configuration,
|
||||
},
|
||||
|
||||
campaignInfo: {
|
||||
...draft.wizardForm.campaignInfo,
|
||||
...data.campaignInfo,
|
||||
},
|
||||
|
||||
budgetItems: {
|
||||
...draft.wizardForm.budgetItems,
|
||||
...data.budgetItems,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
persistDraft();
|
||||
if (markUnsaved) {
|
||||
hasUnsavedChanges.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfiguration(data: Partial<WizardConfiguration>) {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
updateWizard({
|
||||
configuration: {
|
||||
...draft.wizardForm.configuration,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCampaignInfo(data: Partial<WizardCampaignInfo>) {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
updateWizard({
|
||||
campaignInfo: {
|
||||
...draft.wizardForm.campaignInfo,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function addReward(reward: ProjectReward) {
|
||||
const errors = validateReward(reward);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
updateWizard({
|
||||
rewards: [
|
||||
...draft.wizardForm.rewards,
|
||||
{
|
||||
...reward,
|
||||
project: reward.project,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function updateReward(index: number, reward: ProjectReward) {
|
||||
const errors = validateReward(reward);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
const rewards = [...draft.wizardForm.rewards];
|
||||
|
||||
rewards[index] = {
|
||||
...rewards[index],
|
||||
...reward,
|
||||
};
|
||||
|
||||
updateWizard({
|
||||
rewards,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function deleteReward(index: number) {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
updateWizard({
|
||||
rewards: draft.wizardForm.rewards.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
|
||||
export function addCollaboration(collab: ProjectCollaboration): Record<string, string> {
|
||||
const errors = validateCollaboration(collab);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return {};
|
||||
|
||||
updateWizard({
|
||||
collaborations: [...draft.wizardForm.collaborations, { ...collab }],
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function updateCollaboration(
|
||||
index: number,
|
||||
collab: ProjectCollaboration,
|
||||
): Record<string, string> {
|
||||
const errors = validateCollaboration(collab);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return {};
|
||||
|
||||
const collaborations = [...draft.wizardForm.collaborations];
|
||||
|
||||
collaborations[index] = {
|
||||
...collaborations[index],
|
||||
...collab,
|
||||
};
|
||||
|
||||
updateWizard({
|
||||
collaborations,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function deleteCollaboration(index: number) {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
updateWizard({
|
||||
collaborations: draft.wizardForm.collaborations.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
|
||||
export function addBudgetItem(item: ProjectBudgetItem) {
|
||||
const errors = validateBudgetItem(item);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return {};
|
||||
|
||||
updateWizard({
|
||||
budgetItems: {
|
||||
...draft.wizardForm.budgetItems,
|
||||
|
||||
[item.deadline]: [...draft.wizardForm.budgetItems[item.deadline], item],
|
||||
},
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function updateBudgetItem(
|
||||
index: number,
|
||||
item: ProjectBudgetItem,
|
||||
previousDeadline: "minimum" | "optimum" = item.deadline,
|
||||
) {
|
||||
const errors = validateBudgetItem(item);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return {};
|
||||
|
||||
const budgetItems = { ...draft.wizardForm.budgetItems };
|
||||
const previousItems = [...budgetItems[previousDeadline]];
|
||||
const existing = previousItems[index];
|
||||
const updatedItem = {
|
||||
...existing,
|
||||
...item,
|
||||
};
|
||||
|
||||
if (previousDeadline === item.deadline) {
|
||||
previousItems[index] = updatedItem;
|
||||
budgetItems[item.deadline] = previousItems;
|
||||
} else {
|
||||
budgetItems[previousDeadline] = previousItems.filter((_, i) => i !== index);
|
||||
budgetItems[item.deadline] = [...budgetItems[item.deadline], updatedItem];
|
||||
}
|
||||
|
||||
updateWizard({
|
||||
budgetItems,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function deleteBudgetItem(index: number, deadline: "minimum" | "optimum") {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
updateWizard({
|
||||
budgetItems: {
|
||||
...draft.wizardForm.budgetItems,
|
||||
|
||||
[deadline]: draft.wizardForm.budgetItems[deadline].filter((_, i) => i !== index),
|
||||
},
|
||||
});
|
||||
|
||||
hasUnsavedChanges.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update create Project form data
|
||||
*
|
||||
* Merges new create project form updates with already existing project data.
|
||||
*
|
||||
* @param data - Partial object with Project API Type data that has been modified
|
||||
*/
|
||||
export function updateProject(data: Partial<ProjectProjectCreationDto>) {
|
||||
currentDraft.update((draft) => {
|
||||
if (!draft) return draft;
|
||||
|
||||
const updated = {
|
||||
...draft,
|
||||
isDirty: true,
|
||||
createProject: {
|
||||
...draft.createProject,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
persistDraft();
|
||||
hasUnsavedChanges.set(true);
|
||||
}
|
||||
|
||||
export function markCurrentDraftClean(resources?: ProjectDraftResources) {
|
||||
const draft = get(currentDraft);
|
||||
if (!draft) return;
|
||||
|
||||
const nextResources = resources ?? {
|
||||
rewards: draft.wizardForm.rewards,
|
||||
collaborations: draft.wizardForm.collaborations,
|
||||
budgetItems: draft.wizardForm.budgetItems,
|
||||
};
|
||||
|
||||
const cleanDraft = {
|
||||
...draft,
|
||||
isDirty: false,
|
||||
apiSnapshot: nextResources,
|
||||
wizardForm: {
|
||||
...draft.wizardForm,
|
||||
rewards: nextResources.rewards,
|
||||
collaborations: nextResources.collaborations,
|
||||
budgetItems: nextResources.budgetItems,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
currentDraft.set(cleanDraft);
|
||||
draftRepo.update(cleanDraft.draftId, cleanDraft.userId, cleanDraft);
|
||||
hasUnsavedChanges.set(false);
|
||||
}
|
||||
|
||||
export function deleteCurrentDraft(draftId: string, userId: number) {
|
||||
const current = get(currentDraft);
|
||||
|
||||
if (current?.draftId === draftId) {
|
||||
currentDraft.set(null);
|
||||
}
|
||||
|
||||
draftRepo.delete(draftId, userId);
|
||||
touchedFields.set(new Set());
|
||||
isSavingDraft.set(false);
|
||||
persistenceError.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific wizard step
|
||||
*
|
||||
* Navigation rules:
|
||||
* - Can navigate to current step (no-op, returns true)
|
||||
* - Can navigate backward to any previous step
|
||||
* - Can navigate forward only if ALL previous steps are completed
|
||||
* - Updates URL with browser history for back/forward button support
|
||||
*
|
||||
* @param targetStep - The step number to navigate to (1-indexed)
|
||||
* @returns true if navigation was successful, false if blocked
|
||||
*
|
||||
* @example
|
||||
* // Navigate to step 2 (allowed if step 1 is completed)
|
||||
* navigateToStep(2);
|
||||
*
|
||||
* // Navigate back to step 1 (always allowed)
|
||||
* navigateToStep(1);
|
||||
*/
|
||||
export function navigateToStep(targetStep: number): boolean {
|
||||
const state = get(currentDraft);
|
||||
const currentStep = state?.wizardForm.currentStep;
|
||||
|
||||
// No validation - free navigation
|
||||
if (targetStep === currentStep) {
|
||||
console.log(`[wizard-state] Already on step ${targetStep}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[wizard-state] Navigating: ${currentStep} → ${targetStep}`);
|
||||
updateWizard({ currentStep: targetStep });
|
||||
updateUrl(targetStep);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update browser URL with current step (for browser back/forward support)
|
||||
* @param step - Current step number
|
||||
*/
|
||||
export function updateUrl(step: number) {
|
||||
// SSR safety check
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("step", step.toString());
|
||||
window.history.pushState({ step }, "", url.toString());
|
||||
} catch (error) {
|
||||
console.error("[wizard-state] Failed to update URL:", error);
|
||||
}
|
||||
}
|
||||
+48
-39
@@ -2,13 +2,9 @@ import { writable, derived, get } from "svelte/store";
|
||||
|
||||
import { projectsService } from "../services/projectsService";
|
||||
|
||||
import type { Project } from "../openapi/client/types.gen";
|
||||
import type { ApiProjectsGetCollectionData, Project } from "../openapi/client/types.gen";
|
||||
|
||||
export interface SearchFilters {
|
||||
query: string;
|
||||
statusFilter: string;
|
||||
categories: string[];
|
||||
}
|
||||
export type SearchFilters = ApiProjectsGetCollectionData["query"];
|
||||
|
||||
export interface SearchState {
|
||||
filters: SearchFilters;
|
||||
@@ -34,9 +30,10 @@ const isBrowser = typeof window !== "undefined";
|
||||
function getInitialState(): SearchState {
|
||||
return {
|
||||
filters: {
|
||||
query: "",
|
||||
statusFilter: "",
|
||||
categories: [],
|
||||
page: 1,
|
||||
itemsPerPage: 20,
|
||||
title: "",
|
||||
status: "",
|
||||
},
|
||||
results: [],
|
||||
isLoading: false,
|
||||
@@ -61,23 +58,26 @@ function loadInitialState(): SearchState {
|
||||
const state = getInitialState();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Initialize filters from URL parameters
|
||||
state.filters.query = urlParams.get("q") || "";
|
||||
state.filters.statusFilter = urlParams.get("status") || "";
|
||||
// Initialize filters from URL parameters using API field names
|
||||
if (state.filters) {
|
||||
state.filters.title = urlParams.get("title") || "";
|
||||
state.filters.status = urlParams.get("status") || "";
|
||||
|
||||
const categories = urlParams.get("categories");
|
||||
if (categories) {
|
||||
state.filters.categories = categories.split(",").filter(Boolean);
|
||||
// Handle categories[] array parameters
|
||||
const categoriesArray = urlParams.getAll("categories[]");
|
||||
if (categoriesArray.length > 0) {
|
||||
state.filters["categories[]"] = categoriesArray;
|
||||
}
|
||||
// If we have URL parameters, mark as having searched
|
||||
const hasUrlFilters =
|
||||
state.filters.title ||
|
||||
state.filters.status ||
|
||||
(state.filters["categories[]"]?.length ?? 0) > 0;
|
||||
|
||||
if (hasUrlFilters) {
|
||||
state.hasSearched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have URL parameters, mark as having searched
|
||||
const hasUrlFilters =
|
||||
state.filters.query || state.filters.statusFilter || state.filters.categories.length > 0;
|
||||
|
||||
if (hasUrlFilters) {
|
||||
state.hasSearched = true;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -221,9 +221,10 @@ function createSearchStore() {
|
||||
update((state) => ({
|
||||
...state,
|
||||
filters: {
|
||||
query: "",
|
||||
statusFilter: "",
|
||||
categories: [],
|
||||
page: 1,
|
||||
itemsPerPage: 20,
|
||||
title: "",
|
||||
status: "",
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -236,22 +237,26 @@ function createSearchStore() {
|
||||
|
||||
const state = get(searchStore);
|
||||
const urlParams = new URLSearchParams();
|
||||
const filters = state.filters ?? {};
|
||||
|
||||
if (state.filters.query) {
|
||||
urlParams.set("q", state.filters.query);
|
||||
}
|
||||
if (state.filters.statusFilter) {
|
||||
urlParams.set("status", state.filters.statusFilter);
|
||||
}
|
||||
if (state.filters.categories.length > 0) {
|
||||
urlParams.set("categories", state.filters.categories.join(","));
|
||||
}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
urlParams.append(key, String(item));
|
||||
});
|
||||
} else {
|
||||
if (key !== "page" && key !== "itemsPerPage") {
|
||||
urlParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const newUrl = urlParams.toString()
|
||||
? `${window.location.pathname}?${urlParams.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
// Use replaceState to avoid adding to browser history on every filter change
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
},
|
||||
|
||||
@@ -276,7 +281,11 @@ function createSearchStore() {
|
||||
hasPrevPage: pagination?.hasPrev || false,
|
||||
hasSearched:
|
||||
totalCount > 0 ||
|
||||
!!(filters.query || filters.statusFilter || filters.categories.length > 0),
|
||||
!!(
|
||||
filters?.title ||
|
||||
filters?.status ||
|
||||
(filters?.["categories[]"]?.length ?? 0) > 0
|
||||
),
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
errorMessage: "",
|
||||
@@ -469,7 +478,7 @@ export const resultCount = derived(searchStore, ($searchStore) => $searchStore.t
|
||||
// Derived store to check if any filters are active
|
||||
export const hasActiveFilters = derived(searchStore, ($searchStore) => {
|
||||
const { filters } = $searchStore;
|
||||
return !!(filters.query || filters.statusFilter || filters.categories.length > 0);
|
||||
return !!(filters?.title || filters?.status || (filters?.["categories[]"]?.length ?? 0) > 0);
|
||||
});
|
||||
|
||||
// Pagination derived stores
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,8 +30,8 @@ export interface Campaign extends Project {
|
||||
title: string;
|
||||
/** Campaign image URL path */
|
||||
image: string;
|
||||
/** Current funding amount */
|
||||
obtained: Money;
|
||||
/** Current funding amount — optional when fetched client-side per card */
|
||||
obtained?: Money;
|
||||
/** Minimum funding goal */
|
||||
minimum: Money;
|
||||
/** Optional optimum funding goal */
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { getBaseUrl } from "./consts";
|
||||
import { extractId } from "./extractId";
|
||||
import { client } from "../openapi/client/client.gen";
|
||||
import {
|
||||
apiAccountingsIdGetUrl,
|
||||
apiGatewayCheckoutsIdGetUrl,
|
||||
apiProjectsIdOrSlugGetUrl,
|
||||
apiTipjarsIdGetUrl,
|
||||
apiUsersIdOrHandleGetUrl,
|
||||
} from "../openapi/client/paths.gen";
|
||||
|
||||
import type { Accounting, GatewayCheckout, Project, Tipjar, User } from "../openapi/client";
|
||||
|
||||
export async function fetchWithPersistentCache<T>(
|
||||
iri: string,
|
||||
token: string,
|
||||
API_CACHE_NAME: string,
|
||||
): Promise<T> {
|
||||
const cache = await caches.open(API_CACHE_NAME);
|
||||
|
||||
const cached = await cache.match(iri);
|
||||
if (cached) return cached.json();
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const fullUrl = joinUrl(baseUrl, iri);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/ld+json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await cache.put(fullUrl, response.clone());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchAccounting(iri: string | null, token: string, API_CACHE_NAME: string) {
|
||||
if (!iri) return;
|
||||
|
||||
const url = client.buildUrl({ url: apiAccountingsIdGetUrl, path: { id: extractId(iri) } });
|
||||
return fetchWithPersistentCache<Accounting>(url, token, API_CACHE_NAME);
|
||||
}
|
||||
|
||||
export async function fetchUser(iri: string | null, token: string, API_CACHE_NAME: string) {
|
||||
if (!iri) return;
|
||||
|
||||
const url = client.buildUrl({ url: apiUsersIdOrHandleGetUrl, path: { id: extractId(iri) } });
|
||||
return fetchWithPersistentCache<User>(url, token, API_CACHE_NAME);
|
||||
}
|
||||
|
||||
export async function fetchProject(iri: string | null, token: string, API_CACHE_NAME: string) {
|
||||
if (!iri) return;
|
||||
|
||||
const url = client.buildUrl({
|
||||
url: apiProjectsIdOrSlugGetUrl,
|
||||
path: { idOrSlug: extractId(iri) },
|
||||
});
|
||||
return fetchWithPersistentCache<Project>(url, token, API_CACHE_NAME);
|
||||
}
|
||||
|
||||
export async function fetchCheckout(iri: string | null, token: string, API_CACHE_NAME: string) {
|
||||
if (!iri) return;
|
||||
|
||||
const url = client.buildUrl({
|
||||
url: apiGatewayCheckoutsIdGetUrl,
|
||||
path: { id: extractId(iri) },
|
||||
});
|
||||
return fetchWithPersistentCache<GatewayCheckout>(url, token, API_CACHE_NAME);
|
||||
}
|
||||
|
||||
export async function fetchTipjar(iri: string | null, token: string, API_CACHE_NAME: string) {
|
||||
if (!iri) return;
|
||||
|
||||
const url = client.buildUrl({
|
||||
url: apiTipjarsIdGetUrl,
|
||||
path: { id: extractId(iri) },
|
||||
});
|
||||
return fetchWithPersistentCache<Tipjar>(url, token, API_CACHE_NAME);
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string) {
|
||||
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Dexie, type EntityTable } from "dexie";
|
||||
|
||||
import type { Draft } from "../../stores/drafts/projectDraft";
|
||||
|
||||
export const db = new Dexie("goteo-project-drafts") as Dexie & {
|
||||
drafts: EntityTable<Draft, "draftId">;
|
||||
};
|
||||
|
||||
db.version(2).stores({
|
||||
drafts: "draftId, userId, [userId+updatedAt]",
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import Dexie from "dexie";
|
||||
|
||||
import { db } from "./db";
|
||||
|
||||
import type { Draft } from "../../stores/drafts/projectDraft";
|
||||
|
||||
export const draftRepo = {
|
||||
async create(draft: Draft) {
|
||||
await db.drafts.put(draft);
|
||||
return draft;
|
||||
},
|
||||
|
||||
async update(draftId: string, userId: number, data: Partial<Draft>) {
|
||||
const existing = await db.drafts.get(draftId);
|
||||
|
||||
if (!existing || existing.userId !== userId) return;
|
||||
|
||||
await db.drafts.put({
|
||||
...existing,
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
},
|
||||
|
||||
async get(draftId: string) {
|
||||
const draft = await db.drafts.get(draftId);
|
||||
|
||||
if (!draft) return undefined;
|
||||
|
||||
return draft;
|
||||
},
|
||||
|
||||
async getAllByUser(userId: number) {
|
||||
return db.drafts
|
||||
.where("[userId+updatedAt]")
|
||||
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
|
||||
.reverse()
|
||||
.toArray();
|
||||
},
|
||||
|
||||
async delete(draftId: string, userId: number) {
|
||||
const draft = await db.drafts.get(draftId);
|
||||
|
||||
if (!draft || draft.userId !== userId) return;
|
||||
|
||||
await db.drafts.delete(draftId);
|
||||
},
|
||||
};
|
||||
+7
-1
@@ -4,11 +4,17 @@ export function toCollectionItems<T>(collection: unknown): T[] {
|
||||
}
|
||||
|
||||
if (collection && typeof collection === "object") {
|
||||
const hydraMembers = (collection as Record<string, unknown>)["hydra:member"];
|
||||
const record = collection as Record<string, unknown>;
|
||||
const hydraMembers = record["hydra:member"];
|
||||
const members = record.member;
|
||||
|
||||
if (Array.isArray(hydraMembers)) {
|
||||
return hydraMembers as T[];
|
||||
}
|
||||
|
||||
if (Array.isArray(members)) {
|
||||
return members as T[];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -19,7 +19,19 @@ export const STORAGE_MAXSIZE = 8388608;
|
||||
/**
|
||||
* Allowed MIME types of single objects.
|
||||
*/
|
||||
export const STORAGE_ALLOWEDTYPES = ["image/jpeg", "image/webp", "image/png", "image/gif"];
|
||||
export const STORAGE_ALLOWEDTYPES = [
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
];
|
||||
|
||||
export const client = new S3Client({
|
||||
region: import.meta.env.OBJECT_STORAGE_REGION,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
createBudgetItem,
|
||||
createCollaboration,
|
||||
createReward,
|
||||
deleteBudgetItem,
|
||||
deleteCollaboration,
|
||||
deleteReward,
|
||||
patchProject,
|
||||
updateBudgetItem,
|
||||
updateCollaboration,
|
||||
updateReward,
|
||||
} from "./projectSubmissionApi";
|
||||
import { validateDraftToPublish } from "../stores/drafts/draftValidation";
|
||||
|
||||
import type { Session } from "../auth/types";
|
||||
import type { ProjectBudgetItem, ProjectCollaboration, ProjectReward } from "../openapi/client";
|
||||
import type { Draft, ProjectDraftResources } from "../stores/drafts/projectDraft";
|
||||
|
||||
export class PublishValidationError extends Error {
|
||||
constructor(public errors: Record<string, string>) {
|
||||
super("Draft validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
function getIds<T extends { id?: number }>(items: T[]) {
|
||||
return new Set(items.flatMap((item) => (item.id ? [item.id] : [])));
|
||||
}
|
||||
|
||||
function flattenBudgetItems(resources: ProjectDraftResources["budgetItems"]) {
|
||||
return [...resources.minimum, ...resources.optimum];
|
||||
}
|
||||
|
||||
async function assertResult<T>(result: { data?: T; error?: unknown }) {
|
||||
if (result.error) throw result.error;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function syncRewards(current: ProjectReward[], snapshot: ProjectReward[], session: Session) {
|
||||
const currentIds = getIds(current);
|
||||
const deleted = snapshot.filter((reward) => reward.id && !currentIds.has(reward.id));
|
||||
|
||||
await Promise.all(
|
||||
deleted.map(async (reward) => {
|
||||
const result = await deleteReward(reward.id!, session);
|
||||
await assertResult(result);
|
||||
}),
|
||||
);
|
||||
|
||||
const synced = await Promise.all(
|
||||
current.map(async (reward) => {
|
||||
const result = reward.id
|
||||
? await updateReward(reward, session)
|
||||
: await createReward(reward, session);
|
||||
|
||||
return assertResult(result);
|
||||
}),
|
||||
);
|
||||
|
||||
return synced.filter(Boolean) as ProjectReward[];
|
||||
}
|
||||
|
||||
async function syncCollaborations(
|
||||
current: ProjectCollaboration[],
|
||||
snapshot: ProjectCollaboration[],
|
||||
session: Session,
|
||||
) {
|
||||
const currentIds = getIds(current);
|
||||
const deleted = snapshot.filter((collab) => collab.id && !currentIds.has(collab.id));
|
||||
|
||||
await Promise.all(
|
||||
deleted.map(async (collab) => {
|
||||
const result = await deleteCollaboration(collab.id!, session);
|
||||
await assertResult(result);
|
||||
}),
|
||||
);
|
||||
|
||||
const synced = await Promise.all(
|
||||
current.map(async (collab) => {
|
||||
const result = collab.id
|
||||
? await updateCollaboration(collab, session)
|
||||
: await createCollaboration(collab, session);
|
||||
|
||||
return assertResult(result);
|
||||
}),
|
||||
);
|
||||
|
||||
return synced.filter(Boolean) as ProjectCollaboration[];
|
||||
}
|
||||
|
||||
async function syncBudgetItems(
|
||||
current: ProjectDraftResources["budgetItems"],
|
||||
snapshot: ProjectDraftResources["budgetItems"],
|
||||
session: Session,
|
||||
) {
|
||||
const currentItems = flattenBudgetItems(current);
|
||||
const snapshotItems = flattenBudgetItems(snapshot);
|
||||
const currentIds = getIds(currentItems);
|
||||
const deleted = snapshotItems.filter((item) => item.id && !currentIds.has(item.id));
|
||||
|
||||
await Promise.all(
|
||||
deleted.map(async (item) => {
|
||||
const result = await deleteBudgetItem(item.id!, session);
|
||||
await assertResult(result);
|
||||
}),
|
||||
);
|
||||
|
||||
const synced = await Promise.all(
|
||||
currentItems.map(async (item) => {
|
||||
const result = item.id
|
||||
? await updateBudgetItem(item, session)
|
||||
: await createBudgetItem(item, session);
|
||||
|
||||
return assertResult(result);
|
||||
}),
|
||||
);
|
||||
|
||||
const syncedItems = synced.filter(Boolean) as ProjectBudgetItem[];
|
||||
|
||||
return {
|
||||
minimum: syncedItems.filter((item) => item.deadline === "minimum"),
|
||||
optimum: syncedItems.filter((item) => item.deadline === "optimum"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishDraft(draft: Draft, session: Session, projectId: string) {
|
||||
const validationErrors = validateDraftToPublish(draft);
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
throw new PublishValidationError(validationErrors);
|
||||
}
|
||||
|
||||
const wizard = draft.wizardForm;
|
||||
const apiSnapshot = draft.apiSnapshot ?? {
|
||||
rewards: [],
|
||||
collaborations: [],
|
||||
budgetItems: {
|
||||
minimum: [],
|
||||
optimum: [],
|
||||
},
|
||||
};
|
||||
|
||||
const description = [
|
||||
wizard.campaignInfo.objectives,
|
||||
wizard.campaignInfo.legacy,
|
||||
wizard.campaignInfo.targetAudience,
|
||||
wizard.campaignInfo.team,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const result = await patchProject(
|
||||
projectId,
|
||||
{
|
||||
title: draft.createProject.title,
|
||||
subtitle: draft.createProject.subtitle,
|
||||
categories: draft.createProject.categories,
|
||||
video: wizard.campaignInfo.video,
|
||||
description,
|
||||
deadline: wizard.configuration.projectDeadline,
|
||||
},
|
||||
session,
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const [rewards, collaborations, budgetItems] = await Promise.all([
|
||||
syncRewards(wizard.rewards, apiSnapshot.rewards, session),
|
||||
syncCollaborations(wizard.collaborations, apiSnapshot.collaborations, session),
|
||||
syncBudgetItems(wizard.budgetItems, apiSnapshot.budgetItems, session),
|
||||
]);
|
||||
|
||||
return {
|
||||
project: result.data,
|
||||
resources: {
|
||||
rewards,
|
||||
collaborations,
|
||||
budgetItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
apiProjectBudgetItemsGetCollection,
|
||||
apiProjectBudgetItemsIdDelete,
|
||||
apiProjectBudgetItemsIdPatch,
|
||||
apiProjectBudgetItemsPost,
|
||||
apiProjectCollaborationsGetCollection,
|
||||
apiProjectCollaborationsIdDelete,
|
||||
apiProjectCollaborationsIdPatch,
|
||||
apiProjectCollaborationsPost,
|
||||
apiProjectRewardsGetCollection,
|
||||
apiProjectRewardsIdDelete,
|
||||
apiProjectRewardsIdPatch,
|
||||
apiProjectRewardsPost,
|
||||
apiProjectsIdPatch,
|
||||
} from "../openapi/client";
|
||||
|
||||
import type { Session } from "../auth/types";
|
||||
import type {
|
||||
ProjectReward,
|
||||
ProjectCollaboration,
|
||||
ProjectBudgetItem,
|
||||
ProjectProjectUpdationDto,
|
||||
} from "../openapi/client";
|
||||
import type { ProjectDraftResources } from "../stores/drafts/projectDraft";
|
||||
|
||||
export async function patchProject(
|
||||
projectId: string,
|
||||
body: ProjectProjectUpdationDto,
|
||||
session: Session,
|
||||
) {
|
||||
return apiProjectsIdPatch({
|
||||
path: { id: projectId },
|
||||
body,
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createReward(reward: ProjectReward, session: Session) {
|
||||
return apiProjectRewardsPost({
|
||||
body: reward,
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateReward(reward: ProjectReward, session: Session) {
|
||||
if (!reward.id) {
|
||||
throw new Error("Cannot update reward without id");
|
||||
}
|
||||
|
||||
return apiProjectRewardsIdPatch({
|
||||
path: { id: String(reward.id) },
|
||||
body: reward,
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteReward(rewardId: number, session: Session) {
|
||||
return apiProjectRewardsIdDelete({
|
||||
path: { id: String(rewardId) },
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCollaboration(collab: ProjectCollaboration, session: Session) {
|
||||
return apiProjectCollaborationsPost({
|
||||
body: {
|
||||
...collab,
|
||||
isFulfilled: false,
|
||||
},
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCollaboration(collab: ProjectCollaboration, session: Session) {
|
||||
if (!collab.id) {
|
||||
throw new Error("Cannot update collaboration without id");
|
||||
}
|
||||
|
||||
return apiProjectCollaborationsIdPatch({
|
||||
path: { id: String(collab.id) },
|
||||
body: collab,
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCollaboration(collabId: number, session: Session) {
|
||||
return apiProjectCollaborationsIdDelete({
|
||||
path: { id: String(collabId) },
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBudgetItem(item: ProjectBudgetItem, session: Session) {
|
||||
return apiProjectBudgetItemsPost({
|
||||
body: item,
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBudgetItem(item: ProjectBudgetItem, session: Session) {
|
||||
if (!item.id) {
|
||||
throw new Error("Cannot update budget item without id");
|
||||
}
|
||||
|
||||
return apiProjectBudgetItemsIdPatch({
|
||||
path: { id: String(item.id) },
|
||||
body: item,
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBudgetItem(itemId: number, session: Session) {
|
||||
return apiProjectBudgetItemsIdDelete({
|
||||
path: { id: String(itemId) },
|
||||
headers: session.token.asHttpHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjectDraftResources(
|
||||
projectIri: string,
|
||||
session: Session,
|
||||
): Promise<ProjectDraftResources> {
|
||||
const [rewardsResult, collaborationsResult, budgetItemsResult] = await Promise.all([
|
||||
apiProjectRewardsGetCollection({
|
||||
query: { project: projectIri, itemsPerPage: 100 },
|
||||
headers: session.token.asHttpHeaders,
|
||||
}),
|
||||
apiProjectCollaborationsGetCollection({
|
||||
query: { project: projectIri, itemsPerPage: 100 },
|
||||
headers: session.token.asHttpHeaders,
|
||||
}),
|
||||
apiProjectBudgetItemsGetCollection({
|
||||
query: { project: projectIri, itemsPerPage: 100 },
|
||||
headers: session.token.asHttpHeaders,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (rewardsResult.error) throw rewardsResult.error;
|
||||
if (collaborationsResult.error) throw collaborationsResult.error;
|
||||
if (budgetItemsResult.error) throw budgetItemsResult.error;
|
||||
|
||||
const budgetItems = budgetItemsResult.data ?? [];
|
||||
|
||||
return {
|
||||
rewards: rewardsResult.data ?? [],
|
||||
collaborations: collaborationsResult.data ?? [],
|
||||
budgetItems: {
|
||||
minimum: budgetItems.filter((item) => item.deadline === "minimum"),
|
||||
optimum: budgetItems.filter((item) => item.deadline === "optimum"),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user