Merge remote-tracking branch 'origin/develop' into chore/update-astro

# Conflicts:
#	pnpm-lock.yaml
#	src/components/create/CreateProjectForm.svelte
This commit is contained in:
Pep Villalonga
2026-08-03 13:38:48 +02:00
72 changed files with 5183 additions and 3086 deletions
+6
View File
@@ -24,6 +24,12 @@ PUBLIC_TIPPING_TIPJAR_ID=""
PUBLIC_TIPPING_DEFAULT_AMOUNT=""
PUBLIC_TIPPING_DEFAULT_CHECKED="false"
# Campaign limit
# Set a recurring day and month (DD-MM) to prevent projects from being in
# campaign past that date; the year is implicit and repeats every year
# Leave the value empty to disable the limit
PUBLIC_CAMPAIGN_MAX_END_DATE=""
# Facebook's share dialog
# https://developers.facebook.com/docs/sharing/reference/share-dialog
PUBLIC_FACEBOOK_APP_ID=""
+11 -5
View File
@@ -25,7 +25,7 @@ jobs:
download_translations: false
push_translations: false
create_pull_request: false
crowdin_branch_name: develop
crowdin_branch_name: ${{ github.ref_name }}
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
@@ -33,20 +33,26 @@ jobs:
- name: Resolve Crowdin branch and file IDs
id: ids
env:
CROWDIN_BRANCH: ${{ github.ref_name }}
run: |
api="https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}"
auth="Authorization: Bearer ${CROWDIN_PERSONAL_TOKEN}"
branch_id=$(curl -fsS -H "$auth" "$api/branches?name=develop" \
# Crowdin normalizes branch names: \ / : * ? " < > | become "."
branch_name=$(printf '%s' "$CROWDIN_BRANCH" | sed 's#[\\/:*?"<>|]#.#g')
branch_id=$(curl -fsS -G -H "$auth" "$api/branches" \
--data-urlencode "name=${branch_name}" \
| jq -r '.data[0].data.id')
if [ -z "$branch_id" ] || [ "$branch_id" = "null" ]; then
echo "Branch 'develop' not found in Crowdin"; exit 1
echo "Branch '${branch_name}' not found in Crowdin"; exit 1
fi
file_id=$(curl -fsS -H "$auth" "$api/files?branchId=${branch_id}&limit=500" \
file_id=$(curl -fsS -H "$auth" "$api/files?branchId=${branch_id}&recursion=1&limit=500" \
| jq -r '.data[] | select(.data.name=="es.json") | .data.id' | head -n1)
if [ -z "$file_id" ] || [ "$file_id" = "null" ]; then
echo "es.json not found in Crowdin branch 'develop'"; exit 1
echo "es.json not found in Crowdin branch '${branch_name}'"; exit 1
fi
echo "file_id=$file_id" >> "$GITHUB_OUTPUT"
+1
View File
@@ -39,6 +39,7 @@ export default [
".github/**",
"env.d.ts",
"storybook-static/**",
"graphify-out/**",
],
},
{
+1 -1
View File
@@ -78,7 +78,7 @@
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"cypress": "^15.18.1",
"eslint": "^10.0.1",
"eslint": "^9.39.5",
"eslint-plugin-cypress": "^6.4.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-storybook": "^10.5.2",
+281 -245
View File
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -44,22 +44,24 @@ export const payment = defineAction({
}
const charges: GatewayCharge[] = Object.values(cart.items);
const payload = {
origin: session.user.accounting!,
gateway: client.buildUrl({
url: apiGatewaysIdGetUrl,
path: { id: paymentMethod },
}),
returnUrl: `${context.url.origin}/checkout/verify`,
charges,
};
const response = await apiGatewayCheckoutsPost({
const { data, error } = await apiGatewayCheckoutsPost({
headers: session.token.asHttpHeaders,
body: {
origin: session.user.accounting!,
gateway: client.buildUrl({
url: apiGatewaysIdGetUrl,
path: { id: paymentMethod },
}),
returnUrl: `${context.url.origin}/checkout/verify`,
charges,
},
body: payload,
});
if (response.error) {
console.log(response);
if (error) {
console.error(error);
console.log(payload);
throw new ActionError({
code: "BAD_REQUEST",
@@ -67,7 +69,7 @@ export const payment = defineAction({
});
}
return { success: true, checkout: response.data };
return { success: true, checkout: data };
} catch (err) {
console.error(err);
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import { locale, t } from "../../i18n/store";
interface Props {
class?: ClassNameValue;
}
let { class: classes = "" }: Props = $props();
const sections = [
{ path: "/admin/charges", key: "pages.admin.nav.charges" },
{ path: "/admin/projects", key: "pages.admin.nav.projects" },
];
let pathname = $state("");
$effect(() => {
pathname = window.location.pathname.replace(/\/+$/, "");
});
function isActive(path: string): boolean {
return pathname.endsWith(path);
}
</script>
<nav class={twMerge("admin-nav overflow-x-auto px-2 pb-3 md:px-4 md:pb-4", classes)}>
<ul class="flex min-w-max items-center gap-2">
{#each sections as section (section.path)}
{@const active = isActive(section.path)}
<li>
<a
href="/{$locale}{section.path}"
aria-current={active ? "page" : undefined}
class="block rounded-lg px-4 py-2 whitespace-nowrap transition-colors duration-200 {active
? 'bg-secondary text-white'
: 'bg-grey text-secondary'}"
>
{$t(section.key)}
</a>
</li>
{/each}
</ul>
</nav>
<style>
@media (max-width: 768px) {
.admin-nav {
-webkit-overflow-scrolling: touch;
}
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
import { fade, slide } from "svelte/transition";
import type { Component } from "svelte";
import Chevron from "../icons/navigation/Chevron.svelte";
import Button from "../library/buttons/Button.svelte";
import { locale, t } from "../../i18n/store";
export interface AdminSidebarItem {
label: string;
href: string;
icon?: Component;
}
interface Props {
navItems: AdminSidebarItem[];
activePath: string;
class?: ClassNameValue;
}
let { navItems, activePath, class: classes = "" }: Props = $props();
let hoverOpen = $state(false);
let isMobileOpen = $state(false);
function toggleMobile() {
isMobileOpen = !isMobileOpen;
}
function closeMobile() {
isMobileOpen = false;
}
function isActive(item: AdminSidebarItem): boolean {
return item.href === activePath;
}
function navItemClass(item: AdminSidebarItem): ClassNameValue {
return twMerge(
"hover:bg-variant1 flex content-center items-center w-fit gap-2 rounded-lg px-4 py-2 text-base font-bold transition-colors duration-300 text-secondary",
isActive(item) && "bg-variant2 hover:bg-variant3",
!hoverOpen && "justify-center mx-1 w-auto",
);
}
</script>
<!-- Desktop sidebar -->
<aside
class={twMerge(
"bg-purple-soft z-30 hidden shrink-0 flex-col items-start rounded-2xl py-10 pr-10 pl-5 shadow-sm transition-all duration-300 lg:sticky lg:top-(--sticky-top) lg:flex lg:h-[calc(100vh-var(--sticky-top)-1rem)]",
hoverOpen ? "w-56.75" : "w-15 items-center px-0",
classes,
)}
onmouseenter={() => (hoverOpen = true)}
onmouseleave={() => (hoverOpen = false)}
>
<div class={twMerge("absolute top-1 flex items-center p-2", hoverOpen ? "right-2" : "right-3")}>
<Chevron direction={hoverOpen ? "left" : "right"} width="20" height="20" />
</div>
<nav class="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto">
{#each navItems as item}
<a href={`/${$locale}${item.href}`} class={navItemClass(item) || ""}>
{#if hoverOpen}
{#if item.icon}
<item.icon class="size-5 shrink-0" />
{/if}
<span>{item.label}</span>
{:else if item.icon}
<item.icon class="size-5" />
{:else}
<span class="text-secondary truncate text-sm font-bold" title={item.label}>
{item.label.charAt(0)}
</span>
{/if}
</a>
{/each}
</nav>
</aside>
<!-- Mobile backdrop -->
{#if isMobileOpen}
<dialog
open
class="fixed inset-0 z-30 m-0 h-full max-h-none w-full max-w-none border-none bg-black/30 p-0 lg:hidden"
onclick={closeMobile}
aria-label={$t("pages.admin.sidebar.aria.close")}
transition:fade={{ duration: 200 }}
></dialog>
{/if}
<!-- Mobile sidebar overlay -->
{#if isMobileOpen}
<aside
class="bg-purple-soft fixed top-(--sticky-top) left-0 z-40 flex h-[calc(100vh-var(--sticky-top)-1rem)] w-56.75 flex-col gap-2 rounded-r-2xl py-10 pr-10 pl-5 shadow-xl lg:hidden"
transition:slide={{ duration: 300, axis: "x" }}
>
<nav class="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto">
{#each navItems as item}
<a
href={`/${$locale}${item.href}`}
class={twMerge(
"hover:bg-variant1 text-secondary flex w-fit content-center items-center gap-2 rounded-lg px-4 py-2 text-base font-bold transition-colors duration-300",
isActive(item) && "bg-variant2 hover:bg-variant3",
)}
onclick={closeMobile}
>
{#if item.icon}
<item.icon class="size-5 shrink-0" />
{/if}
<span>{item.label}</span>
</a>
{/each}
</nav>
</aside>
{/if}
<!-- Mobile floating toggle -->
<Button
kind="secondary"
onclick={toggleMobile}
class={twMerge(
"bg-purple-soft border-secondary/10 fixed top-1/2 z-50 h-12 w-12 -translate-y-1/2 cursor-pointer rounded-full border p-0 shadow-lg transition-all duration-300 lg:hidden",
isMobileOpen ? "left-57" : "left-1",
)}
aria-label={$t("pages.admin.sidebar.aria.open")}
>
<Chevron direction={isMobileOpen ? "left" : "right"} width="22" height="22" />
</Button>
+56 -5
View File
@@ -12,6 +12,7 @@
import {
apiAccountingsIdGet,
apiGatewayChargesGetCollection,
apiGatewayChargestotalsGetCollection,
apiGatewayCheckoutsIdGet,
apiGatewaysGetCollection,
apiProjectsIdOrSlugGet,
@@ -19,6 +20,7 @@
apiUsersIdOrHandleGet,
type Accounting,
type ApiGatewayChargesGetCollectionData,
type ApiGatewayChargestotalsGetCollectionData,
type GatewayCharge,
type GatewayCheckout,
type Project,
@@ -72,6 +74,7 @@
let isFirstLoad = $state(true);
let selectedSort = $state(initialSort?.key ?? "date-desc");
let totalTips = $state<string>("—");
let selectedProjectsCount = $state<number | string>("—");
async function loadTotalTips() {
if (!isEnabled || !$session) return;
@@ -90,6 +93,36 @@
}
}
// Distinct count of Projects targeted by the charges matching the current filters.
// Served by the /v4/gateway_charges/totals aggregate (same filters as the collection).
let projectsCountRequestId = 0;
async function loadSelectedProjectsCount(
chargeFilters: ApiGatewayChargesGetCollectionData["query"],
) {
const requestId = ++projectsCountRequestId;
selectedProjectsCount = "—";
const headers = {
Accept: "application/ld+json",
...($session?.token.asHttpHeaders ?? {}),
};
const { data, error } = await apiGatewayChargestotalsGetCollection({
query: chargeFilters as ApiGatewayChargestotalsGetCollectionData["query"],
headers,
});
if (requestId !== projectsCountRequestId) return;
if (error || data?.projects === undefined) {
console.error("Failed to load selected projects count:", error);
return;
}
selectedProjectsCount = data.projects;
}
function buildChargesQuery(
filters: ApiGatewayChargesGetCollectionData["query"],
page: number,
@@ -363,6 +396,23 @@
reloadCharges();
});
// Plain (non-reactive) bookkeeping: avoids re-triggering the effect below on write.
let prevProjectsCountKey: string | undefined;
$effect(() => {
const currentFilters = Object.fromEntries(
Object.entries(filters ?? {}).filter(
([, value]) => value !== undefined && value !== "",
),
) as ApiGatewayChargesGetCollectionData["query"];
const key = JSON.stringify(currentFilters);
if (key === prevProjectsCountKey) return;
prevProjectsCountKey = key;
loadSelectedProjectsCount(currentFilters);
});
$effect(() => {
const sort = sortOptions.find((option) => option.key === selectedSort);
@@ -380,10 +430,10 @@
});
let chargeSlides = $derived([
{ title: $t("pages.admin.charges.totalizers.selected"), amount: $totalItems },
{ title: $t("pages.admin.charges.totalizers.totalCharges"), amount: "—" },
{ title: $t("pages.admin.charges.totalizers.totalTips"), amount: totalTips },
{ title: $t("pages.admin.charges.totalizers.totalFees"), amount: "—" },
{ title: $t("domain.charges.totalizers.selected"), amount: selectedProjectsCount },
{ title: $t("domain.charges.totalizers.totalCharges"), amount: "—" },
{ title: $t("domain.charges.totalizers.totalTips"), amount: totalTips },
{ title: $t("domain.charges.totalizers.totalFees"), amount: "—" },
]);
onMount(async () => {
@@ -391,7 +441,7 @@
paymentMethodOptions = [
["all", $t("pages.admin.charges.filters.paymentMethod.options.all")],
...(paymentGateways ?? []).map((g): [string, string] => [g.id!, g.name!]),
...(paymentGateways ?? []).map((g): [string, string] => [g.id!, g.name ?? ""]),
];
chargeStatusOptions = Object.entries(
@@ -418,6 +468,7 @@
{paymentMethodOptions}
{chargeStatusOptions}
{rangeAmountOptions}
composedFiltersResource={["gateway_charge"]}
onApplyFilters={handleApplyFilters}
/>
<div class="flex flex-col">
+33 -8
View File
@@ -5,17 +5,29 @@
import { type ApiGatewayChargesGetCollectionData } from "../../openapi/client/index";
import FiltersIcon from "../icons/filters/Filters.svelte";
import Button from "../library/buttons/Button.svelte";
import FilterComposer from "../library/filters/FilterComposer.svelte";
import DateInput from "../library/inputs/DateInput.svelte";
import Grid from "../library/layout/Grid.svelte";
let { filters, onApplyFilters, paymentMethodOptions, chargeStatusOptions, rangeAmountOptions } =
$props<{
filters: ApiGatewayChargesGetCollectionData["query"];
onApplyFilters: (filters: any) => void;
paymentMethodOptions: [string, string][];
chargeStatusOptions: [string, string][];
rangeAmountOptions: [string, string][];
}>();
import type { FilterResource } from "../../utils/filterComposer";
let {
filters,
onApplyFilters,
paymentMethodOptions,
chargeStatusOptions,
rangeAmountOptions,
composedFiltersResource,
initialSearchQuery = "",
} = $props<{
filters: ApiGatewayChargesGetCollectionData["query"];
onApplyFilters: (filters: any) => void;
paymentMethodOptions: [string, string][];
chargeStatusOptions: [string, string][];
rangeAmountOptions: [string, string][];
composedFiltersResource: FilterResource[];
initialSearchQuery?: string;
}>();
let showFilters = $state(false);
@@ -25,6 +37,8 @@
let dateFrom = $state("");
let dateTo = $state("");
let composerParams = $state<Record<string, string | string[]>>({});
// Debounce auto-applied filter changes so they don't fire one API request per change
let autoApplyTimeout: ReturnType<typeof setTimeout>;
@@ -35,6 +49,7 @@
function applyFilters() {
onApplyFilters({
...filters,
...composerParams,
"checkout.gateway": selectedPaymentMethod || undefined,
status: selectedChargeStatus || undefined,
"money.amount[gte]": selectedRangeAmount || undefined,
@@ -179,5 +194,15 @@
</Button>
</div>
</form>
<div class="border-secondary border-t pt-4">
<h4 class="text-secondary mb-3 text-sm font-medium">
{$t("pages.admin.charges.filters.composer.title")}
</h4>
<FilterComposer
resource={composedFiltersResource}
onParamsChange={(params) => (composerParams = params)}
/>
</div>
{/if}
</div>
+1 -1
View File
@@ -46,7 +46,7 @@
{ 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.chargeStatus", sortable: true, 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
];
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import CartItem from "./CartItem.svelte";
import Tipjar from "./Tipjar.svelte";
import { cart, cartByRecipient } from "../../stores/cart";
import { cart, cartByRecipient } from "../../stores/checkoutsStore";
import * as tipping from "../../utils/tipping";
function increment(item: { key: string; quantity: number }) {
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { cartCount } from "../../stores/cart";
import { cartCount } from "../../stores/checkoutsStore";
import Bag from "../icons/commerce/Bag.svelte";
</script>
<a href="/checkout">
<Bag items={$cartCount > 1 ? $cartCount : undefined} />
<Bag items={$cartCount > 0 ? $cartCount : undefined} />
</a>
+5 -5
View File
@@ -3,12 +3,12 @@
import { formatCurrency } from "../../utils/currencies";
import MoreAndLess from "../icons/filters/MoreAndLess.svelte";
import type { CartItem } from "../../stores/cart";
import type { CheckoutItem } from "../../stores/checkoutsStore";
export let item: CartItem;
export let onIncrement: (item: CartItem) => void;
export let onDecrement: (item: CartItem) => void;
export let onRemove: (item: CartItem) => void;
export let item: CheckoutItem;
export let onIncrement: (item: CheckoutItem) => void;
export let onDecrement: (item: CheckoutItem) => void;
export let onRemove: (item: CheckoutItem) => void;
function handleDecrement() {
if (item.quantity === 1) {
+29 -31
View File
@@ -87,8 +87,11 @@ const { accounting } = Astro.props;
</section>
<script>
import { actions, isInputError } from "astro:actions";
import type { GatewayLink } from "../../openapi/client/index";
import { multiplyMoney, sumMoney } from "../../utils/money";
import { cart } from "../../stores/checkoutsStore";
const form = document.getElementById("payment");
const errorContent = document.getElementById("payment-error-content");
const cartDataInput = document.getElementById("cart-data-input");
@@ -109,7 +112,7 @@ const { accounting } = Astro.props;
if (!error) {
const checkoutLink = data?.checkout?.links?.find(
(link: { type: string }) => link.type === "payment",
(link: GatewayLink) => link.type === "payment",
);
if (!checkoutLink) {
@@ -164,38 +167,33 @@ const { accounting } = Astro.props;
const labels = document.querySelectorAll("label[data-gateway]");
const inputs = document.querySelectorAll("input[name='paymentMethod']");
const walletInput = document.querySelector("input[value='wallet']");
const cartData = localStorage.getItem("cart");
if (cartData && cartDataInput) {
(cartDataInput as HTMLInputElement).value = cartData;
}
if (cartData && walletInput) {
try {
const cart = JSON.parse(cartData);
const items = Object.values(cart.items ?? {}) as Array<{
money: { amount: number; currency?: string };
quantity: number;
}>;
const total =
sumMoney(items.map((item) => multiplyMoney(item.money, item.quantity)))
?.amount ?? 0;
const balanceAttr = form?.dataset.balance;
const balance = balanceAttr ? parseFloat(balanceAttr) : 0;
const shouldDisable = balance < total;
(walletInput as HTMLInputElement).disabled = shouldDisable;
const walletLabel = walletInput.closest("label");
if (walletLabel) {
walletLabel.classList.toggle("opacity-50", shouldDisable);
walletLabel.classList.toggle("cursor-not-allowed", shouldDisable);
}
} catch (e) {
console.log("Error parsing cart data:", e);
cart.subscribe((state) => {
if (cartDataInput instanceof HTMLInputElement) {
cartDataInput.value = JSON.stringify(state);
}
}
if (!walletInput) return;
const total =
sumMoney(
Object.values(state.items).map((item) =>
multiplyMoney(item.money, item.quantity),
),
)?.amount ?? 0;
const balanceAttr = form?.dataset.balance;
const balance = balanceAttr ? parseFloat(balanceAttr) : 0;
const shouldDisable = balance < total;
(walletInput as HTMLInputElement).disabled = shouldDisable;
const walletLabel = walletInput.closest("label");
if (walletLabel) {
walletLabel.classList.toggle("opacity-50", shouldDisable);
walletLabel.classList.toggle("cursor-not-allowed", shouldDisable);
}
});
inputs.forEach((input) => {
input.addEventListener("change", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import WarningIcon from "../../components/icons/status/Warning.svelte";
import { t } from "../../i18n/store";
import { cartAmount, cartByRecipient } from "../../stores/cart";
import { cartAmount, cartByRecipient } from "../../stores/checkoutsStore";
import { formatCurrency } from "../../utils/currencies";
import { multiplyMoney, sumMoney } from "../../utils/money";
import CollapsibleBox from "../library/layout/CollapsibleBox.svelte";
+4 -4
View File
@@ -3,7 +3,7 @@
import { t } from "../../i18n/store";
import { apiTipjarsIdGet } from "../../openapi/client";
import { cart, cartByRecipient, type CartItem } from "../../stores/cart";
import { cart, cartByRecipient, type CheckoutItem } from "../../stores/checkoutsStore";
import { getUnit } from "../../utils/currencies";
import * as tipping from "../../utils/tipping";
@@ -23,7 +23,7 @@
};
}
async function getTip(): Promise<Omit<CartItem, "key">> {
async function getTip(): Promise<Omit<CheckoutItem, "key">> {
if ($cartByRecipient[tipping.tipjarIri]) {
return $cartByRecipient[tipping.tipjarIri][0];
}
@@ -64,11 +64,11 @@
function toggleTip() {
if (!isChecked) {
if ($cartByRecipient[tipping.tipjarIri].length < 1) {
const tip = $cartByRecipient[tipping.tipjarIri]?.[0];
if (!tip) {
return;
}
const tip = $cartByRecipient[tipping.tipjarIri][0];
cart.removeItem(tip.key);
} else {
setTip(amount * getUnit());
@@ -3,12 +3,10 @@ import WalletInfo from "./WalletInfo.svelte";
const { t } = Astro.locals;
const { accounting } = Astro.props;
const defaultCurrency = import.meta.env.PUBLIC_DEFAULT_CURRENCY;
---
<section>
<WalletInfo client:load {accounting} {defaultCurrency} />
<WalletInfo client:load {accounting} />
<form
id="payment"
@@ -35,6 +33,8 @@ const defaultCurrency = import.meta.env.PUBLIC_DEFAULT_CURRENCY;
<script>
import { actions, isInputError } from "astro:actions";
import { cart } from "../../stores/checkoutsStore";
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("payment");
const errorContent = document.getElementById("payment-error-content");
@@ -43,15 +43,9 @@ const defaultCurrency = import.meta.env.PUBLIC_DEFAULT_CURRENCY;
if (!(form instanceof HTMLFormElement) || !(cartDataInput instanceof HTMLInputElement))
return;
const cartData = localStorage.getItem("cart");
if (cartData) {
try {
JSON.parse(cartData);
cartDataInput.value = cartData;
} catch (e) {
console.error("Error parsing cart data:", e);
}
}
cart.subscribe((state) => {
cartDataInput.value = JSON.stringify(state);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
+2 -11
View File
@@ -1,17 +1,8 @@
<script lang="ts">
import { derived } from "svelte/store";
import { t } from "../../i18n/store";
import { cart } from "../../stores/cart";
import { getDefaultCurrency } from "../../utils/consts";
import { cartAmount as total } from "../../stores/checkoutsStore";
import { formatCurrency } from "../../utils/currencies";
import { multiplyMoney, sumMoney, subtractMoney } from "../../utils/money";
const total = derived(cart, ($cart) => {
const items = Object.values($cart?.items ?? {});
if (items.length === 0) return { amount: 0, currency: getDefaultCurrency() };
return sumMoney(items.map((item) => multiplyMoney(item.money, item.quantity)));
});
import { subtractMoney } from "../../utils/money";
export let accounting;
</script>
@@ -26,6 +26,7 @@
validationErrors,
type CreateProjectForm,
} from "../../stores/drafts/projectDraft";
import { maxEndDate } from "../../utils/campaign";
import { getDefaultCurrency } from "../../utils/consts";
import { formatCurrency } from "../../utils/currencies";
import Button from "../library/buttons/Button.svelte";
@@ -373,6 +374,7 @@
name="release"
bind:value={releaseDate}
min={getMinDate()}
max={maxEndDate}
error={shouldShowError("release") ? $t($validationErrors.release) : undefined}
onBlur={() => handleFieldBlur("release")}
onInput={(date: any) => handleFieldChange("release", date)}
+1 -1
View File
@@ -28,7 +28,7 @@ const { data: projects } = await apiProjectsGetCollection({
});
const campaigns: Campaign[] = await Promise.all(
projects!.map(async (project, index) => {
(projects ?? []).map(async (project, index) => {
const { data: accounting } = (await client.get({
url: client.buildUrl({ url: project.accounting! }),
})) as { data: Accounting };
+1 -1
View File
@@ -31,7 +31,7 @@ const { data: projects } = await apiProjectsGetCollection({
});
const campaigns: Campaign[] = await Promise.all(
projects!.map(async (project) => {
(projects ?? []).map(async (project) => {
const { data: accounting } = (await client.get({
url: client.buildUrl({ url: project.accounting! }),
})) as { data: Accounting };
@@ -24,12 +24,12 @@
<div
class={twMerge(
"border-grey hover:bg-purple-soft hover:border-variant1 cursor-pointer border bg-white p-4 text-start",
"border-grey hover:bg-purple-soft hover:border-variant1 cursor-pointer border bg-white text-start",
classes,
)}
>
{#if variant === "multiselect"}
<label class="flex cursor-pointer justify-between">
<label class="flex cursor-pointer justify-between p-4">
<span class="text-base text-black">{option.label}</span>
<input
type="checkbox"
@@ -40,7 +40,7 @@
</label>
{:else if variant === "basic"}
<button
class="w-full cursor-pointer text-base text-black"
class="w-full cursor-pointer p-4 text-base text-black"
onclick={() => handleChange(option)}
>
{option.label}
@@ -12,6 +12,7 @@
interface Props {
class?: ClassNameValue;
searchClasses?: ClassNameValue;
variant: DropdownVariant;
options: DropdownOption[];
selected?: DropdownOption[];
@@ -33,6 +34,7 @@
let {
class: classes = undefined,
searchClasses = undefined,
variant,
options = $bindable([]),
selected = $bindable([]),
@@ -106,7 +108,10 @@
>
{#if hasSearch}
<div
class="group flex w-full items-center justify-between rounded-lg border border-gray-100 bg-white p-4 shadow-sm outline-none focus:outline-none"
class={twMerge(
"group flex w-full items-center justify-between rounded-lg border border-gray-100 bg-white p-4 shadow-sm outline-none focus:outline-none",
searchClasses,
)}
>
<input
class="max-h-6 w-full border-0 bg-white p-0 text-base/6 font-normal text-black ring-0 placeholder:opacity-48"
@@ -0,0 +1,81 @@
<script lang="ts">
import FilterComposerRow from "./FilterComposerRow.svelte";
import { t } from "../../../i18n/store";
import {
getAllFilterSubjects,
createFilterRow,
type FilterRow as FilterRowType,
type FilterOperator,
type FilterResource,
} from "../../../utils/filterComposer";
import PlusIcon from "../../icons/actions/PlusIcon.svelte";
import Button from "../buttons/Button.svelte";
interface RowState {
subjectKey: string;
operator: FilterOperator | "";
referent: string | number | Date | string[];
}
interface Props {
resource?: FilterResource;
onParamsChange?: (params: Record<string, string | string[]>) => void;
}
let { resource = undefined, onParamsChange }: Props = $props();
let subjects = $derived(getAllFilterSubjects(resource));
let rows = $state<RowState[]>([]);
let params = $derived.by(() => {
const result: Record<string, string | string[]> = {};
for (const row of rows) {
if (!row.subjectKey || !row.operator || !row.referent) continue;
const subject = subjects.find((s) => s.key === row.subjectKey);
if (!subject) continue;
const filterRow: FilterRowType = createFilterRow(
subject,
row.operator as FilterOperator,
row.referent,
);
Object.assign(result, filterRow.serialize());
}
return result;
});
$effect(() => {
onParamsChange?.(params);
});
function addRow() {
rows = [...rows, { subjectKey: "", operator: "", referent: "" }];
}
function removeRow(index: number) {
rows = rows.filter((_, i) => i !== index);
}
</script>
<div class="flex flex-col gap-4">
{#each rows as row, i}
<FilterComposerRow
bind:subjectKey={row.subjectKey}
bind:operator={row.operator}
bind:referent={row.referent}
{subjects}
onremove={() => removeRow(i)}
/>
{/each}
<div>
<Button class="" kind="ghost" onclick={addRow}>
<PlusIcon />
{$t("pages.admin.charges.filters.composer.addFilter")}
</Button>
</div>
</div>
@@ -0,0 +1,325 @@
<script lang="ts">
import { clickOutside } from "flowbite-svelte";
import { t } from "../../../i18n/store";
import Close from "../../icons/navigation/Close.svelte";
import DropdownMenu from "../dropdown/DropdownMenu.svelte";
import DateInput from "../inputs/DateInput.svelte";
import Select from "../inputs/Select.svelte";
import TextInput from "../inputs/TextInput.svelte";
import type {
FilterSubject,
FilterOperator,
FilterOption,
} from "../../../utils/filterComposer";
import type { DropdownOption } from "../dropdown/dropdown.types";
interface Props {
subjects: FilterSubject[];
subjectKey?: string;
operator?: FilterOperator | "";
referent?: string | number | Date | string[];
onremove: () => void;
}
let {
subjects,
subjectKey = $bindable(""),
operator = $bindable("" as FilterOperator | ""),
referent = $bindable("" as string | number | Date | string[]),
onremove,
}: Props = $props();
let compatibleOperators = $derived(
subjects.find((s) => s.key === subjectKey)?.compatibleOperators ?? [],
);
let currentSubject = $derived(subjects.find((s) => s.key === subjectKey));
let dropdownOptions = $state<DropdownOption[]>([]);
let dropdownSelected = $state<DropdownOption[]>([]);
let showSuggestSearch = $state(true);
let showStaticDropdown = $state(true);
let previousSubjectKey = $state("");
$effect(() => {
if (subjectKey === previousSubjectKey) return;
previousSubjectKey = subjectKey;
operator = "";
const subject = subjects.find((s) => s.key === subjectKey);
if (!subject || !subject.options) {
dropdownOptions = [];
dropdownSelected = [];
return;
}
dropdownOptions = subject.options.map((o) => ({
id: o.value,
label: o.label,
selected: false,
}));
dropdownSelected = [];
});
function handleStaticChange(option: DropdownOption) {
const current = Array.isArray(referent) ? referent : [];
const updated = option.selected
? [...current, option.id]
: current.filter((id) => id !== option.id);
referent = updated;
if (updated.length === 0) {
showStaticDropdown = true;
}
}
async function handleSuggest(q: string) {
if (!currentSubject?.suggest) return;
if (!q) {
dropdownOptions = [...dropdownSelected];
return;
}
const results: FilterOption[] = await currentSubject.suggest(q);
dropdownOptions = results.map((r) => ({
id: r.value,
label: r.label,
selected: dropdownSelected.some((s) => s.id === r.value),
}));
}
function handleSuggestChange(option: DropdownOption) {
if (operator === "equals") {
if (option.selected) {
dropdownSelected = [option];
referent = option.id;
showSuggestSearch = false;
} else {
dropdownSelected = [];
referent = "";
showSuggestSearch = true;
}
} else {
const current = Array.isArray(referent) ? referent : [];
const updated = option.selected
? [...current, option.id]
: current.filter((id) => id !== option.id);
referent = updated;
if (updated.length === 0) {
showSuggestSearch = true;
}
}
}
function handleClearSuggestTag() {
dropdownSelected = [];
referent = "";
showSuggestSearch = true;
}
function handleRemoveTag(item: DropdownOption) {
dropdownSelected = dropdownSelected.filter((s) => s.id !== item.id);
referent = (referent as string[]).filter((id) => id !== item.id);
if (dropdownSelected.length === 0) {
showStaticDropdown = true;
showSuggestSearch = true;
}
}
function subjectLabel(key: string): string {
return $t(`pages.admin.charges.filters.composer.subject.${key}`);
}
function operatorLabel(op: FilterOperator): string {
return $t(`pages.admin.charges.filters.composer.operator.${op}`);
}
</script>
<div class="flex items-center gap-3">
<div class="flex-1">
<Select
bind:value={subjectKey}
labelText={$t("pages.admin.charges.filters.composer.subjectPlaceholder")}
>
<option value="">{$t("pages.admin.charges.filters.composer.subjectPlaceholder")}</option
>
{#each subjects as subject}
<option value={subject.key}>{subjectLabel(subject.key)}</option>
{/each}
</Select>
</div>
<div class="flex-1">
<Select
bind:value={operator}
disabled={!subjectKey}
labelText={$t("pages.admin.charges.filters.composer.operatorPlaceholder")}
>
<option value=""
>{$t("pages.admin.charges.filters.composer.operatorPlaceholder")}</option
>
{#each compatibleOperators as op}
<option value={op}>{operatorLabel(op)}</option>
{/each}
</Select>
</div>
<div class="flex-1">
{#if currentSubject?.options && operator === "equals" && subjectKey}
<Select
bind:value={referent as string}
disabled={!operator}
labelText={$t("pages.admin.charges.filters.composer.referentPlaceholder")}
>
<option value=""
>{$t("pages.admin.charges.filters.composer.referentPlaceholder")}</option
>
{#each currentSubject.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</Select>
{:else if currentSubject?.options && operator === "is_any_of" && subjectKey}
{#if !showStaticDropdown && dropdownSelected.length > 0}
<div
class="border-secondary flex min-h-14 cursor-pointer flex-wrap items-center gap-2 rounded-lg border bg-white p-3"
onclick={() => (showStaticDropdown = true)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (showStaticDropdown = true)}
>
{#each dropdownSelected as item}
<span
class="bg-tertiary/10 border-secondary inline-flex items-center gap-1 rounded-lg border px-3 py-1 text-sm"
>
{item.label}
<button
type="button"
class="text-tertiary hover:text-tertiary/80 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
handleRemoveTag(item);
}}
>
<Close width="12" height="12" />
</button>
</span>
{/each}
</div>
{:else}
<div use:clickOutside={() => (showStaticDropdown = false)}>
<DropdownMenu
searchClasses="border-secondary"
variant="multiselect"
options={dropdownOptions}
bind:selected={dropdownSelected}
onChange={handleStaticChange}
chevronLabel={$t(
"pages.admin.charges.filters.composer.referentPlaceholder",
)}
/>
</div>
{/if}
{:else if currentSubject?.suggest && subjectKey && operator === "equals"}
{#if referent && !Array.isArray(referent) && !showSuggestSearch}
<div
class="border-secondary flex items-center justify-between rounded-lg border bg-white p-4"
>
<button
type="button"
class="text-secondary cursor-pointer text-sm hover:underline"
onclick={handleClearSuggestTag}
>
{dropdownSelected[0]?.label ?? referent}
</button>
<button
type="button"
onclick={handleClearSuggestTag}
class="text-tertiary hover:text-tertiary/80 cursor-pointer"
aria-label={$t("pages.admin.charges.filters.composer.removeFilter")}
>
<Close width="16" height="16" />
</button>
</div>
{:else}
<DropdownMenu
searchClasses="border-secondary"
variant="multiselect"
options={dropdownOptions}
bind:selected={dropdownSelected}
hasSearch
onSearch={handleSuggest}
onChange={handleSuggestChange}
/>
{/if}
{:else if currentSubject?.suggest && subjectKey && operator}
{#if !showSuggestSearch && dropdownSelected.length > 0}
<div
class="border-secondary flex min-h-14 cursor-pointer flex-wrap items-center gap-2 rounded-lg border bg-white p-3"
onclick={() => (showSuggestSearch = true)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (showSuggestSearch = true)}
>
{#each dropdownSelected as item}
<span
class="bg-tertiary/10 border-secondary inline-flex items-center gap-1 rounded-lg border px-3 py-1 text-sm"
>
{item.label}
<button
type="button"
class="text-tertiary hover:text-tertiary/80 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
handleRemoveTag(item);
}}
>
<Close width="12" height="12" />
</button>
</span>
{/each}
</div>
{:else}
<div use:clickOutside={() => (showSuggestSearch = false)}>
<DropdownMenu
searchClasses="border-secondary"
variant="multiselect"
options={dropdownOptions}
bind:selected={dropdownSelected}
hasSearch
onSearch={handleSuggest}
onChange={handleSuggestChange}
/>
</div>
{/if}
{:else if currentSubject?.type === "date"}
<DateInput
value={typeof referent === "string" && referent ? new Date(referent) : new Date()}
disabled={!subjectKey || !operator}
onInput={(date) => (referent = date)}
labelText={$t("pages.admin.charges.filters.composer.referentPlaceholder")}
/>
{:else if currentSubject?.type === "number"}
<TextInput
bind:value={referent as number}
disabled={!subjectKey || !operator}
type="number"
labelText={$t("pages.admin.charges.filters.composer.referentPlaceholder")}
/>
{:else}
<TextInput
bind:value={referent as string}
disabled={!subjectKey || !operator}
labelText={$t("pages.admin.charges.filters.composer.referentPlaceholder")}
/>
{/if}
</div>
<button
type="button"
onclick={onremove}
class="mb-0.5 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-white"
aria-label={$t("pages.admin.charges.filters.composer.removeFilter")}
>
<Close />
</button>
</div>
@@ -203,7 +203,7 @@
aria-expanded={open}
aria-describedby={error ? `${finalId}-error` : helperText ? `helper-${finalId}` : undefined}
class={twMerge(
"border-secondary text-secondary flex w-full items-center justify-between rounded-md border bg-white p-4 text-base focus:outline-none",
"border-secondary text-secondary flex w-full items-center justify-between rounded-md border bg-white p-4 text-base hover:cursor-pointer focus:outline-none",
disabled && "cursor-not-allowed",
error && "border-red-500 focus:ring-red-500",
)}
@@ -247,7 +247,7 @@
<button
type="button"
onclick={apply}
class="bg-primary text-secondary mt-6 h-14 w-full rounded-3xl text-lg font-bold"
class="bg-primary text-secondary mt-6 h-14 w-full rounded-3xl text-lg font-bold hover:cursor-pointer"
>
{$t("common.dateInput.apply")}
</button>
@@ -302,6 +302,7 @@
border-radius: 9999px;
color: var(--color-secondary);
background: transparent;
cursor: pointer;
}
.datepicker :global(#datepicker-dropdown [role="columnheader"]) {
+2 -2
View File
@@ -41,7 +41,7 @@
)}
>
{#if label !== undefined && value}
<label for={id} class="absolute -top-3 left-3 bg-white px-1 text-sm text-black">
<label for={id} class="absolute -top-2.5 left-6 bg-white px-1 text-sm text-black">
{label}
</label>
{/if}
@@ -55,7 +55,7 @@
{oninput}
{onkeydown}
data-testid={dataTestId}
class="flex-1 border-none bg-white text-black outline-none focus:ring-0"
class="flex-1 border-none bg-white text-black outline-none autofill:shadow-[inset_0_0_0_1000px_var(--color-white)] autofill:[-webkit-text-fill-color:var(--color-black)] focus:ring-0"
/>
{#if value && onclear}
+1 -1
View File
@@ -37,7 +37,7 @@
</span>
{:else if hasReached(project.budget?.minimum?.money)}
<span
class="border-secondary absolute top-3 right-3 rounded-4xl border bg-white px-2 py-1 text-xs text-nowrap"
class="border-secondary absolute top-3 left-3 rounded-4xl border bg-white px-2 py-1 text-xs text-nowrap"
>
{$t("domain.project.campaign.minimumReached")}
</span>
@@ -183,12 +183,21 @@
fill: "start",
tension: 0.25,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBorderWidth: 2,
pointHoverBackgroundColor: "#fff",
pointHoverBorderColor: "#59E9D3",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
axis: "x",
},
scales: {
x: {
type: "linear",
@@ -210,6 +219,9 @@
plugins: {
legend: { display: false },
tooltip: {
mode: "index",
intersect: false,
position: "nearest",
callbacks: {
title: (items) => {
const dataIndex = items[0].dataIndex;
@@ -67,7 +67,7 @@
? groupedItems.default
: groupedItems.default?.slice(
0,
PAGE_SIZE - 2 + Math.max(0, (currentPage - 1) * PAGE_SIZE),
PAGE_SIZE - 1 + Math.max(0, (currentPage - 1) * PAGE_SIZE),
),
);
@@ -114,12 +114,10 @@
{#if groupedItems.default?.length}
<Grid class="grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#if !hasMatchfunding}
<div class="row-span-2 h-full">
<ProjectCommunityAnonymous
{project}
currency={accounting.balance?.currency!}
/>
</div>
<ProjectCommunityAnonymous
{project}
currency={accounting.balance?.currency!}
/>
{/if}
{#each visibleDefaultItems as item (item.id)}
<ProjectCommunityMessage
@@ -145,7 +143,7 @@
<Modal
bind:open={openModal}
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary rounded-4xl hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="fixed top-1/2 left-1/2 w-full max-w-118.75 -translate-x-1/2 -translate-y-1/2 bg-transparent backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
class="fixed top-1/2 left-1/2 w-full max-w-118.75 -translate-x-1/2 -translate-y-1/2 divide-y-0 bg-transparent backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
bodyClass="p-0"
>
{#if selectedProjectSupport}
@@ -27,9 +27,9 @@
</script>
<div
class="border-grey flex cursor-pointer overflow-hidden rounded-4xl border bg-white shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg md:flex-row"
class="border-grey flex h-58 w-full cursor-pointer overflow-hidden rounded-4xl border bg-white shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg md:flex-row"
>
<div class="flex h-48 w-full shrink-0 overflow-hidden md:h-auto md:w-2/5">
<div class="flex h-full w-2/5 shrink-0 overflow-hidden">
<img
src="/images/home/card-comunidad.png"
alt="Community donation"
+2 -1
View File
@@ -5,7 +5,7 @@
apiAccountingsIdGet,
apiProjectRewardsGetCollection,
} from "../../openapi/client/index";
import { cart } from "../../stores/cart";
import { cart, checkoutReady } from "../../stores/checkoutsStore";
import { getUnit } from "../../utils/currencies";
import { extractId } from "../../utils/extractId";
import Button from "../library/buttons/Button.svelte";
@@ -75,6 +75,7 @@
},
});
await checkoutReady();
window.location.href = "/checkout";
}
</script>
+1 -1
View File
@@ -11,5 +11,5 @@
{#each project.categories as category}
<CategoryTag iri={category} />
{/each}
<TerritoryTag territory={project.territory} />
<TerritoryTag territory={project.territory} iconSize="24" />
</div>
+2 -1
View File
@@ -3,7 +3,7 @@
import { onMount } from "svelte";
import { t } from "../../i18n/store";
import { cart } from "../../stores/cart";
import { cart, checkoutReady } from "../../stores/checkoutsStore";
import { getDefaultCurrency } from "../../utils/consts";
import { formatCurrency, getUnit } from "../../utils/currencies";
import { lt } from "../../utils/money";
@@ -61,6 +61,7 @@
});
if (action === "checkout") {
await checkoutReady();
window.location.href = "/checkout";
} else {
open = false;
+3 -1
View File
@@ -14,10 +14,12 @@
territory,
lang: initialLang = undefined,
class: classes = "",
iconSize = "32",
}: {
territory: Territory;
lang?: Locale;
class?: ClassNameValue;
iconSize?: string | number;
} = $props();
const lang = initialLang || $locale;
@@ -44,6 +46,6 @@
</script>
<Tag class={classes}>
<MapIcon />
<MapIcon width={iconSize} height={iconSize} />
<span>{displayName(territory)}</span>
</Tag>
@@ -201,8 +201,8 @@
<!-- Right section: Action Buttons -->
<div class="flex shrink-0 items-center gap-4">
<Button kind="ghost" size="md" disabled={true}>
<Eye />
<Button class="whitespace-nowrap" kind="ghost" size="md" disabled={true}>
<Eye class="size-5" />
{$t("common.preview")}
</Button>
<ActionableButton
@@ -5,7 +5,6 @@
import DeleteModal from "./DeleteModal.svelte";
import RewardItemsSelector from "./RewardItemsSelector.svelte";
import { t } from "../../../i18n/store";
import { type Project, type ProjectReward } from "../../../openapi/client";
import { client } from "../../../openapi/client/client.gen";
import { apiProjectsIdOrSlugGetUrl } from "../../../openapi/client/paths.gen";
import { validationErrors } from "../../../stores/drafts/projectDraft";
@@ -17,6 +16,7 @@
import TextArea from "../../library/inputs/TextArea.svelte";
import TextInput from "../../library/inputs/TextInput.svelte";
import type { Project, ProjectReward } from "../../../openapi/client";
import type { UploadedFile } from "../../../stores/drafts/projectDraft";
let {
@@ -71,7 +71,6 @@ Integrated with searchStore for state management and URL synchronization
onSearch={(title) => updateFilters({ title })}
onEnter={handleSearch}
onClear={handleSearch}
placeholder={$t("domain.search.bar.placeholder")}
data-testid="search-input"
/>
</div>
+1 -1
View File
@@ -56,5 +56,5 @@
onsubmit={() => onEnter?.() || onSearch?.(searchQuery)}
onclear={clearSearch}
data-testid={dataTestId}
class="transition-colors focus-within:border-[#855a96] {className}"
class=" {className}"
/>
@@ -15,6 +15,7 @@ Manages real-time filtering of campaigns without page reloads
searchError,
searchResults,
isSearching,
isLoadingMore,
hasSearchResults,
hasActualSearchResults,
isEmpty,
@@ -200,14 +201,25 @@ Manages real-time filtering of campaigns without page reloads
<!-- Results Grid (keep visible during load more) -->
{#if campaigns.length > 0}
<Grid class="auto-rows-fr grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each campaigns as campaign}
<!-- Render campaign cards using the Svelte CampaignCard component -->
<div class="campaign-card-wrapper" data-campaign-id={campaign.id}>
<CampaignCard size={campaign.size!} {campaign} />
<div class="relative">
<!-- Overlay spinner while re-searching (filters/input changed), not on load more -->
{#if $isSearching && !$isLoadingMore}
<div
class="absolute inset-0 z-10 flex items-start justify-center rounded-2xl bg-white/60 pt-12 backdrop-blur-[1px]"
data-testid="results-loading-overlay"
>
<LoadingSpinner />
</div>
{/each}
</Grid>
{/if}
<Grid class="auto-rows-fr grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each campaigns as campaign}
<!-- Render campaign cards using the Svelte CampaignCard component -->
<div class="campaign-card-wrapper" data-campaign-id={campaign.id}>
<CampaignCard size={campaign.size!} {campaign} />
</div>
{/each}
</Grid>
</div>
{/if}
</div>
+1324 -1058
View File
File diff suppressed because it is too large Load Diff
+1324 -1058
View File
File diff suppressed because it is too large Load Diff
+175 -27
View File
@@ -26,6 +26,9 @@
"project": {
"terms": {
"required": "Debes aceptar las condiciones del impulsor y las bases de la convocatoria"
},
"release": {
"max": "La fecha de inicio es demasiado tardía: la campaña no puede extenderse más allá de la fecha límite de la plataforma"
}
},
"errors": {
@@ -102,6 +105,13 @@
"users": "Usuarios",
"tipjars": "Botes de propina",
"tipjarId": "ID del bote de propina"
},
"lastContributions": "Últimos aportes:",
"totalizers": {
"selected": "Proyectos seleccionados:",
"totalCharges": "Total aportes:",
"totalTips": "Total propinas:",
"totalFees": "Comisiones totales:"
}
},
"review": {
@@ -116,7 +126,7 @@
},
"navigation": {
"search": {
"placeholder": "Busca tu campaña"
"placeholder": "Busca tu proyecto"
}
}
},
@@ -944,16 +954,16 @@
}
},
"search": {
"title": "Buscar campañas - Goteo",
"description": "Explora nuestro catálogo de campañas y encuentra la que más te inspire para hacer tu donación",
"heading": "Encuentra la campaña que buscas",
"subtitle": "Explora nuestro catálogo de campañas y elige la que más te inspire para hacer tu donación",
"topResults": "Campañas más buscadas",
"showingTopResults": "Mostrando las {{count}} campañas más populares",
"loading": "Buscando campañas...",
"title": "Buscar Proyectos - Goteo",
"description": "Explora nuestro catálogo de proyectos y encuentra la que más te inspire para hacer tu donación",
"heading": "Encuentra los Proyectos que buscas",
"subtitle": "Explora nuestro catálogo de proyectos y elige la que más te inspire para hacer tu donación",
"topResults": "Proyectos más buscadas",
"showingTopResults": "Mostrando las {{count}} proyectos más populares",
"loading": "Buscando proyectos...",
"input": {
"placeholder": "Buscar",
"label": "Buscar campañas",
"placeholder": "Buscar proyectos...",
"label": "Buscar proyectos",
"clear": "Limpiar búsqueda",
"button": "Buscar"
},
@@ -967,20 +977,20 @@
"deselectCategory": "Deseleccionar categoría",
"selectedCategories": "{{count}} categorías seleccionadas",
"status": {
"label": "Estado de la campaña"
"label": "Estado de la proyecto"
},
"territory": {
"label": "Ámbito de actuación"
}
},
"empty": {
"title": "No se encontraron campañas",
"description": "No hay campañas que coincidan con tu búsqueda actual. Verifica la ortografía o prueba con palabras clave diferentes o términos más generales.",
"title": "No se encontraron proyectos",
"description": "No hay proyectos que coincidan con tu búsqueda actual. Verifica la ortografía o prueba con palabras clave diferentes o términos más generales.",
"clearFilters": "Limpiar filtros"
},
"initial": {
"title": "Busca tu campaña ideal",
"description": "Utiliza los filtros de arriba para encontrar campañas que te inspiren."
"description": "Utiliza los filtros de arriba para encontrar proyectos que te inspiren."
},
"results": {
"count": "{{count}} resultados encontrados",
@@ -989,7 +999,7 @@
"showing": "Mostrando {{count}} resultados",
"empty": {
"title": "No se encontraron resultados",
"description": "No hay campañas que coincidan con tu búsqueda actual.",
"description": "No hay proyectos que coincidan con tu búsqueda actual.",
"clearFilters": "Limpiar filtros"
}
},
@@ -1021,7 +1031,7 @@
}
},
"accessibility": {
"resultsFound": "Se encontraron {{count}} campañas"
"resultsFound": "Se encontraron {{count}} proyectos"
},
"loadMore": {
"button": "Cargar más",
@@ -1086,6 +1096,80 @@
}
},
"admin": {
"filter": {
"btns": {
"openFilters": "Filtros",
"closeFilters": "Cerrar filtros",
"apply": "Aplicar filtros"
},
"composer": {
"title": "Filtros personalizados",
"addFilter": "Añadir filtro",
"removeFilter": "Eliminar",
"subjectPlaceholder": "Seleccionar campo",
"operatorPlaceholder": "Seleccionar condición",
"referentPlaceholder": "Valor",
"subject": {
"title": "Título",
"subtitle": "Subtítulo",
"description": "Descripción",
"projectStatus": "Estado de proyecto",
"chargeStatus": "Estado de aporte",
"categories": "Categoría",
"owner": "Propietario",
"slug": "ID",
"territoryCountry": "Territorio",
"dateUpdated": "Última actualización",
"budgetAmount": "Importe",
"gateway": "Pasarela",
"type": "Tipo",
"target": "Destino",
"currency": "Moneda",
"amount": "Importe",
"dateCreated": "Fecha de creación",
"handle": "Nombre de usuario",
"email": "Email",
"displayName": "Nombre público",
"userType": "Tipo de usuario",
"active": "Activo",
"userRoles": "Roles",
"userTerritoryCountry": "Ubicación"
},
"operator": {
"equals": "Igual a",
"is_any_of": "Incluido en",
"before": "Anterior a",
"after": "Posterior a",
"strictly_before": "Estrictamente anterior",
"strictly_after": "Estrictamente posterior",
"gte": "Mayor o igual que",
"gt": "Mayor que",
"lte": "Menor o igual que",
"lt": "Menor que"
},
"subjectOption": {
"userType": {
"individual": "Persona física",
"organization": "Persona jurídica (empresas)"
},
"active": {
"true": "Activo",
"false": "Inactivo"
}
}
}
},
"nav": {
"charges": "Cobros",
"projects": "Proyectos",
"users": "Usuarios"
},
"sidebar": {
"aria": {
"close": "Cerrar menú",
"open": "Abrir menú de administración"
}
},
"charges": {
"title": "Aportes",
"description": "Aquí tienes las últimas contribuciones a campañas, puedes ordenarlas y filtrarlas como quieras.",
@@ -1133,7 +1217,9 @@
"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)"
"amount-asc": "Importe (menor a mayor)",
"status-asc": "Estado (A-Z)",
"status-desc": "Estado (Z-A)"
}
},
"itemsPerPage": {
@@ -1188,11 +1274,6 @@
"to_wallet": "Por reembolsar a monedero",
"walleted": "Reembolsado a monedero"
}
},
"btns": {
"openFilters": "Filtros",
"closeFilters": "Cerrar filtros",
"apply": "Aplicar filtros"
}
},
"pagination": {
@@ -1241,11 +1322,6 @@
"btn": "Buscar",
"minLength": "Es necesario meter más de 4 caracteres"
},
"btns": {
"openFilters": "Filtros",
"closeFilters": "Cerrar filtros",
"apply": "Aplicar filtros"
},
"campaignStatus": {
"title": "Estado de campaña",
"options": {
@@ -1389,6 +1465,78 @@
"showing": "Mostrando {{ items }} de {{ total }}",
"itemsPerPage": "{{ count }} por página"
}
},
"users": {
"title": "Usuarios",
"description": "Aquí tienes todos los usuarios de la plataforma, puedes buscarlos y filtrarlos como quieras.",
"lastUsers": "Listado de usuarios:",
"export": {
"csv": "Exportar .csv"
},
"totalizers": {
"selected": "Usuarios seleccionados:"
},
"filters": {
"search": {
"placeholder": "Buscar por nombre de usuario...",
"btn": "Buscar",
"minLength": "Es necesario escribir al menos 4 caracteres"
},
"btns": {
"openFilters": "Filtros",
"closeFilters": "Cerrar filtros",
"apply": "Aplicar filtros"
},
"email": {
"title": "Email"
},
"order": {
"title": "Ordenar por",
"options": {
"handle-asc": "Nombre de usuario A–Z",
"handle-desc": "Nombre de usuario Z–A"
}
},
"itemsPerPage": {
"title": "Líneas por página",
"options": {
"10": "10",
"25": "25",
"50": "50"
}
}
},
"table": {
"headers": {
"handle": "Usuario",
"email": "Email",
"displayName": "Nombre",
"type": "Tipo",
"active": "Activo",
"roles": "Roles"
},
"rows": {
"noData": "No hay datos disponibles",
"active": "Activo",
"inactive": "Inactivo",
"type": {
"individual": "Individual",
"organization": "Organización"
},
"details": {
"id": "ID",
"handle": "Usuario",
"email": "Email",
"type": "Tipo",
"territory": "Ubicación",
"active": "Activo"
}
}
},
"pagination": {
"showing": "Mostrando {{ items }} de {{ total }}",
"itemsPerPage": "{{ count }} por página"
}
}
},
"static": {
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import Header from "./Header.svelte";
import HeaderSubmenu from "./HeaderSubmenu.svelte";
import AdminNav from "../components/admin/AdminNav.svelte";
</script>
<Header>
{#snippet nav()}
<AdminNav />
{/snippet}
<HeaderSubmenu />
</Header>
+16 -4
View File
@@ -3,6 +3,7 @@
import { get } from "svelte/store";
import { twMerge, type ClassNameValue } from "tailwind-merge";
import AdminHeader from "./AdminHeader.svelte";
import Footer from "./Footer.svelte";
import Header from "./Header.svelte";
import HeaderSubmenu from "./HeaderSubmenu.svelte";
@@ -20,9 +21,16 @@
session?: Session;
children?: Snippet;
class?: ClassNameValue;
admin?: boolean;
}
let { locale: localeProp, session: sessionProp, children, class: classes }: AppState = $props();
let {
locale: localeProp,
session: sessionProp,
children,
class: classes,
admin = false,
}: AppState = $props();
const HAD_SESSION_KEY = "goteo-had-session";
@@ -118,9 +126,13 @@
});
</script>
<Header>
<HeaderSubmenu />
</Header>
{#if admin}
<AdminHeader />
{:else}
<Header>
<HeaderSubmenu />
</Header>
{/if}
<main
class={twMerge(
"mt-(--sticky-top) flex w-full max-w-screen flex-1 flex-col lg:max-h-none",
+73 -67
View File
@@ -12,7 +12,7 @@
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props();
let { children, nav }: { children?: Snippet; nav?: Snippet } = $props();
let menuOpen = $state(false);
@@ -38,8 +38,7 @@
id="header-container"
>
<div
class="flex w-full items-center justify-between px-2 py-3 md:px-4 md:py-6"
id="header-main"
class="flex flex-col"
{@attach (el) => {
const observer = new ResizeObserver(() => {
document.documentElement.style.setProperty(
@@ -51,73 +50,80 @@
return () => observer.disconnect();
}}
>
<div class="flex items-center gap-2 md:gap-4">
<a href="/" class="shrink-0"><Logo /></a>
<div class="hidden sm:block">
<HeaderButtons />
<div
class="flex w-full items-center justify-between px-2 py-3 md:px-4 md:py-6"
id="header-main"
>
<div class="flex items-center gap-2 md:gap-4">
<a href="/" class="shrink-0"><Logo /></a>
<div class="hidden sm:block">
<HeaderButtons />
</div>
</div>
</div>
<nav>
<ul class="flex items-center gap-1 md:gap-4">
{#if $session}
<li class="flex items-center pr-2">
<CartButton />
</li>
{/if}
<li class="flex items-center gap-1 pr-2 text-sm md:text-base">
<nav>
<ul class="flex items-center gap-1 md:gap-4">
{#if $session}
<div class="relative inline-block w-full">
<button
onclick={userDropdownToggle}
use:clickOutside={userDropdownClose}
class="flex w-full cursor-pointer items-center gap-1"
>
<UserIcon />
<span class="hidden sm:inline">
{$t("common.greeting")}, {$session.user.displayName}
</span>
</button>
<div
class="absolute top-full left-0 mt-2 w-full min-w-30 flex-col rounded-lg bg-white p-2 shadow-lg"
class:hidden={!userDropdownOpen}
class:flex={userDropdownOpen}
>
<a
href="/me"
class="border-grey text-secondary hover:bg-grey block w-full overflow-hidden border-b px-4 py-2 font-bold text-ellipsis"
>
{$t("common.header.goToProfile")}
</a>
<a
href="/logout"
class="text-secondary hover:bg-grey block w-full cursor-pointer overflow-hidden px-4 py-2 font-bold text-ellipsis"
>
{$t("pages.logout.label")}
</a>
</div>
</div>
{:else}
<a href="/login" class="flex w-full items-center gap-1">
<UserIcon />
<span class="hidden sm:inline">{$t("common.login")}</span>
</a>
<li class="flex items-center pr-2">
<CartButton />
</li>
{/if}
</li>
<li class="hidden items-center pr-2 md:flex">
<UiLanguages />
</li>
<li class="flex items-center {children ? '' : 'sm:hidden'}">
<button class="p-1" onclick={menuToggle}>
<Hamburger
close={menuOpen}
class="text-primary"
barClass="fill-secondary"
/>
</button>
</li>
</ul>
</nav>
<li class="flex items-center gap-1 pr-2 text-sm md:text-base">
{#if $session}
<div class="relative inline-block w-full">
<button
onclick={userDropdownToggle}
use:clickOutside={userDropdownClose}
class="flex w-full cursor-pointer items-center gap-1"
>
<UserIcon />
<span class="hidden sm:inline">
{$t("common.greeting")}, {$session.user.displayName}
</span>
</button>
<div
class="absolute top-full left-0 mt-2 w-full min-w-30 flex-col rounded-lg bg-white p-2 shadow-lg"
class:hidden={!userDropdownOpen}
class:flex={userDropdownOpen}
>
<a
href="/me"
class="border-grey text-secondary hover:bg-grey block w-full overflow-hidden border-b px-4 py-2 font-bold text-ellipsis"
>
{$t("common.header.goToProfile")}
</a>
<a
href="/logout"
class="text-secondary hover:bg-grey block w-full cursor-pointer overflow-hidden px-4 py-2 font-bold text-ellipsis"
>
{$t("pages.logout.label")}
</a>
</div>
</div>
{:else}
<a href="/login" class="flex w-full items-center gap-1">
<UserIcon />
<span class="hidden sm:inline">{$t("common.login")}</span>
</a>
{/if}
</li>
<li class="hidden items-center pr-2 md:flex">
<UiLanguages />
</li>
<li class="flex items-center {children ? '' : 'sm:hidden'}">
<button class="p-1" onclick={menuToggle}>
<Hamburger
close={menuOpen}
class="text-primary"
barClass="fill-secondary"
/>
</button>
</li>
</ul>
</nav>
</div>
{@render nav?.()}
</div>
<div
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Sidebar from "../components/admin/AdminSidebar.svelte";
import type { AdminSidebarItem } from "../components/admin/AdminSidebar.svelte";
interface Props {
navItems: AdminSidebarItem[];
activePath: string;
children: Snippet;
}
let { navItems, activePath, children }: Props = $props();
</script>
<div class="mb-20 w-full px-2 md:px-6">
<div class="mx-auto flex max-w-360 px-2">
<Sidebar {navItems} {activePath} />
<div class="flex min-w-0 flex-1 flex-col gap-10 pt-10 pl-6">
{@render children?.()}
</div>
</div>
</div>
+6 -2
View File
@@ -4,6 +4,7 @@ import { checkAuth } from "./firewall";
import { getLanguage, getUserLangPreferences, persistLanguage } from "./utils";
import { getSession } from "../auth/session";
import { useTranslations } from "../i18n/utils";
import { goto } from "../utils/navigation";
import type { Locale } from "../i18n/locales/index";
import type { APIContext } from "astro";
@@ -13,8 +14,11 @@ export const onRequest = defineMiddleware(async (context: APIContext, next) => {
switch (auth.type) {
case "basic-auth":
return auth.response;
case "unauthorized":
return context.rewrite("/login");
case "unauthorized": {
const callback = context.url.pathname + context.url.search;
return goto("/login", { query: { callback } });
}
case "forbidden":
return context.rewrite("/403");
}
+2
View File
@@ -28,6 +28,8 @@ export const apiGatewaysIdGetUrl = '/v4/gateways/{id}';
export const apiGatewayChargesGetCollectionUrl = '/v4/gateway_charges';
export const apiGatewayChargestotalsGetCollectionUrl = '/v4/gateway_charges/totals';
export const apiGatewayChargesIdGetUrl = '/v4/gateway_charges/{id}';
export const apiGatewayChargesIdPatchUrl = '/v4/gateway_charges/{id}';
File diff suppressed because one or more lines are too long
+139 -35
View File
@@ -575,6 +575,36 @@ export type GatewayChargeChargeUpdationDto = {
status?: 'to_charge' | 'in_charge' | 'to_refund' | 'refunded' | 'to_wallet' | 'walleted';
};
/**
* A Charge represents a payment item to be included in a Checkout for payment at a Gateway.
*/
export type GatewayChargeChargesTotalsDto = {
/**
* The number of distinct Projects targeted by the charges matching the filter set.
*/
projects?: number;
/**
* The aggregated monetary value in the charges.
*/
money?: TotalizedMoney;
};
/**
* A Charge represents a payment item to be included in a Checkout for payment at a Gateway.
*/
export type GatewayChargeChargesTotalsDtoJsonld = {
readonly '@id'?: string;
readonly '@type'?: string;
/**
* The number of distinct Projects targeted by the charges matching the filter set.
*/
projects?: number;
/**
* The aggregated monetary value in the charges.
*/
money?: TotalizedMoneyJsonld;
};
/**
* A Charge represents a payment item to be included in a Checkout for payment at a Gateway.
*/
@@ -1510,7 +1540,7 @@ export type Project = {
/**
* The status of a Project represents how far it is in it's life-cycle.
*/
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.request_change' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.request_change' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
/**
* List of the ProjectRewards this Project offers.
*/
@@ -1558,11 +1588,10 @@ export type ProjectProjectCreationDto = {
*/
categories: Array<string>;
/**
* Desired date-time of release for the created Project.\
* By default 28 days from now, at minimum 14 days from now.
* Deadlines and important Project dates.
*/
release?: string;
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.request_change' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.request_change' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
calendar?: ProjectCalendar;
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
};
/**
@@ -1582,11 +1611,10 @@ export type ProjectProjectCreationDtoJsonld = {
*/
categories: Array<string>;
/**
* Desired date-time of release for the created Project.\
* By default 28 days from now, at minimum 14 days from now.
* Deadlines and important Project dates.
*/
release?: string;
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.request_change' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.request_change' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
calendar?: ProjectCalendarJsonld;
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
};
/**
@@ -1620,6 +1648,10 @@ export type ProjectProjectUpdationDto = {
* and then until the optimum deadline if it did raise the minimum.
*/
deadline?: 'minimum' | 'optimum';
/**
* Deadlines and important Project dates.
*/
calendar?: ProjectCalendar;
/**
* A URL to a video showcasing the Project.
*/
@@ -1627,7 +1659,7 @@ export type ProjectProjectUpdationDto = {
/**
* The status of a Project represents how far it is in it's life-cycle.
*/
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.request_change' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.request_change' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
};
/**
@@ -1691,7 +1723,7 @@ export type ProjectJsonld = {
/**
* The status of a Project represents how far it is in it's life-cycle.
*/
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.request_change' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.request_change' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
status?: 'in_draft' | 'to_campaign_review' | 'in_campaign_review' | 'in_campaign_review.to_change' | 'in_campaign_review.to_review' | 'campaign_review.rejected' | 'to_campaign' | 'in_campaign' | 'campaign.failed' | 'campaign.cancelled' | 'to_funding_review' | 'in_funding_review' | 'in_funding_review.to_change' | 'in_funding_review.to_review' | 'funding_review.rejected' | 'to_funding' | 'in_funding' | 'funding.paid';
/**
* List of the ProjectRewards this Project offers.
*/
@@ -1799,21 +1831,17 @@ export type ProjectBudgetItemJsonld = {
export type ProjectCalendar = {
/**
* The date at which the Project started campaigning.
* Public campaign start date.
*/
release?: string | null;
release: string | null;
/**
* 40 days after the date of release.\
* \
* The minimum budget must be raised by the end of this date.\
* Failure to do so will move the Project out of status `in_campaign` into status `unfunded`.
* The minimum budget must be raised by the end of this date,
* failure to do so will move the Project out of status `in_campaign` into status `unfunded`.
*/
minimum?: string | null;
/**
* 40 days after the minimum deadline. Optional.\
* \
* If the Project achieved their minimum budget by the minimum deadline,
* and this deadline is defined, it may still remain in campaign to raise the optimum.
* and this deadline is defined, it may still remain `in_campaign` to raise the optimum.
*/
optimum?: string | null;
};
@@ -1827,21 +1855,17 @@ export type ProjectCalendarJsonld = {
readonly '@id'?: string;
readonly '@type'?: string;
/**
* The date at which the Project started campaigning.
* Public campaign start date.
*/
release?: string | null;
release: string | null;
/**
* 40 days after the date of release.\
* \
* The minimum budget must be raised by the end of this date.\
* Failure to do so will move the Project out of status `in_campaign` into status `unfunded`.
* The minimum budget must be raised by the end of this date,
* failure to do so will move the Project out of status `in_campaign` into status `unfunded`.
*/
minimum?: string | null;
/**
* 40 days after the minimum deadline. Optional.\
* \
* If the Project achieved their minimum budget by the minimum deadline,
* and this deadline is defined, it may still remain in campaign to raise the optimum.
* and this deadline is defined, it may still remain `in_campaign` to raise the optimum.
*/
optimum?: string | null;
};
@@ -2101,8 +2125,8 @@ export type ProjectSupport = {
* When `anonymous` is *true* the origin will only be public to admins and the User.
*/
readonly origin?: string | null;
readonly displayName?: string;
readonly displayImage?: string;
readonly displayName?: string | null;
readonly displayImage?: string | null;
/**
* The Transactions that were issued to the Project by the origin.
*/
@@ -2176,8 +2200,8 @@ export type ProjectSupportJsonld = {
* When `anonymous` is *true* the origin will only be public to admins and the User.
*/
readonly origin?: string | null;
readonly displayName?: string;
readonly displayImage?: string;
readonly displayName?: string | null;
readonly displayImage?: string | null;
/**
* The Transactions that were issued to the Project by the origin.
*/
@@ -2406,6 +2430,25 @@ export type TipjarJsonld = {
name: string;
};
export type TotalizedMoney = {
amount?: number;
currency?: string;
length?: number;
};
export type TotalizedMoneyJsonld = {
'@context'?: string | {
'@vocab': string;
hydra: 'http://www.w3.org/ns/hydra/core#';
[key: string]: unknown | string | 'http://www.w3.org/ns/hydra/core#';
};
readonly '@id'?: string;
readonly '@type'?: string;
amount?: number;
currency?: string;
length?: number;
};
export type Tracking = {
/**
* A descriptive title for the tracking number.
@@ -3145,6 +3188,10 @@ export type ApiGatewayChargesGetCollectionData = {
'money.amount[gte]'?: string;
'money.amount[lt]'?: string;
'money.amount[lte]'?: string;
'order[money.amount]'?: 'asc' | 'desc';
'order[status]'?: 'asc' | 'desc';
'order[dateCreated]'?: 'asc' | 'desc';
'order[dateUpdated]'?: 'asc' | 'desc';
'dateCreated[before]'?: string;
'dateCreated[strictly_before]'?: string;
'dateCreated[after]'?: string;
@@ -3153,8 +3200,6 @@ export type ApiGatewayChargesGetCollectionData = {
'dateUpdated[strictly_before]'?: string;
'dateUpdated[after]'?: string;
'dateUpdated[strictly_after]'?: string;
'order[dateCreated]'?: 'asc' | 'desc';
'order[dateUpdated]'?: 'asc' | 'desc';
};
url: '/v4/gateway_charges';
};
@@ -3168,6 +3213,52 @@ export type ApiGatewayChargesGetCollectionResponses = {
export type ApiGatewayChargesGetCollectionResponse = ApiGatewayChargesGetCollectionResponses[keyof ApiGatewayChargesGetCollectionResponses];
export type ApiGatewayChargestotalsGetCollectionData = {
body?: never;
path?: never;
query?: {
'checkout.gateway'?: string;
'checkout.gateway[]'?: Array<string>;
'checkout.trackings.value'?: string;
'checkout.trackings.value[]'?: Array<string>;
type?: string;
'type[]'?: Array<string>;
target?: string;
'target[]'?: Array<string>;
'money.currency'?: string;
'money.currency[]'?: Array<string>;
status?: string;
'status[]'?: Array<string>;
'money.amount[between]'?: string;
'money.amount[gt]'?: string;
'money.amount[gte]'?: string;
'money.amount[lt]'?: string;
'money.amount[lte]'?: string;
'order[money.amount]'?: 'asc' | 'desc';
'order[status]'?: 'asc' | 'desc';
'order[dateCreated]'?: 'asc' | 'desc';
'order[dateUpdated]'?: 'asc' | 'desc';
'dateCreated[before]'?: string;
'dateCreated[strictly_before]'?: string;
'dateCreated[after]'?: string;
'dateCreated[strictly_after]'?: string;
'dateUpdated[before]'?: string;
'dateUpdated[strictly_before]'?: string;
'dateUpdated[after]'?: string;
'dateUpdated[strictly_after]'?: string;
};
url: '/v4/gateway_charges/totals';
};
export type ApiGatewayChargestotalsGetCollectionResponses = {
/**
* Charges totals
*/
200: GatewayChargeChargesTotalsDto;
};
export type ApiGatewayChargestotalsGetCollectionResponse = ApiGatewayChargestotalsGetCollectionResponses[keyof ApiGatewayChargestotalsGetCollectionResponses];
export type ApiGatewayChargesIdGetData = {
body?: never;
path: {
@@ -4176,6 +4267,18 @@ export type ApiProjectsGetCollectionData = {
description?: string;
status?: string;
'status[]'?: Array<string>;
'calendar.release[before]'?: string;
'calendar.release[strictly_before]'?: string;
'calendar.release[after]'?: string;
'calendar.release[strictly_after]'?: string;
'calendar.minimum[before]'?: string;
'calendar.minimum[strictly_before]'?: string;
'calendar.minimum[after]'?: string;
'calendar.minimum[strictly_after]'?: string;
'calendar.optimum[before]'?: string;
'calendar.optimum[strictly_before]'?: string;
'calendar.optimum[after]'?: string;
'calendar.optimum[strictly_after]'?: string;
'dateCreated[before]'?: string;
'dateCreated[strictly_before]'?: string;
'dateCreated[after]'?: string;
@@ -5432,6 +5535,7 @@ export type ApiUsersGetCollectionData = {
*/
itemsPerPage?: number;
handle?: string;
'order[handle]'?: 'asc' | 'desc';
email?: string;
accounting?: string;
'accounting[]'?: Array<string>;
+1 -1
View File
@@ -6,7 +6,7 @@ const { t, lang, session } = Astro.locals;
---
<Layout title={t("pages.admin.charges.title")} description={t("pages.metadata.description")}>
<App client:load locale={lang} session={session}>
<App client:load locale={lang} session={session} admin>
<div class="wrapper">
<div class="flex w-full flex-col gap-10 py-10">
<div class="flex flex-col gap-4">
+1 -1
View File
@@ -7,7 +7,7 @@ const { t, lang, session } = Astro.locals;
---
<Layout title={t("pages.admin.projects.title")} description={t("pages.metadata.description")}>
<App client:load locale={lang} session={session}>
<App client:load locale={lang} session={session} admin>
<div class="wrapper">
<div class="flex w-full flex-col gap-10 py-10">
<div class="flex flex-col gap-4">
+6 -1
View File
@@ -181,7 +181,12 @@ const postPaymentUrl = checkoutId
</Card>
</div>
</div>
<script>import {cart} from "../../../stores/cart"; cart.clear();</script>
<div id="checkout-clear" data-user-id={user.id} />
<script>
import {clearForUser} from "../../../stores/checkoutsStore";
clearForUser( document.getElementById("checkout-clear")?.dataset.userId,
);
</script>
</>
)
}
@@ -1,5 +1,7 @@
import { z } from "zod";
import { maxEndDate } from "../../../utils/campaign";
const territorySchema = z
.object({
country: z.string().nullable().optional(),
@@ -67,6 +69,20 @@ export const projectCreationSchema = z.object({
},
{ message: "system.validation.project.release.min" },
)
.refine(
(date) => {
// Release must not be past the platform-wide cutoff
// (PUBLIC_CAMPAIGN_MAX_END_DATE)
if (!maxEndDate) return true;
// Create a copy of the input date to avoid mutation
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
return normalizedDate <= maxEndDate;
},
{ message: "system.validation.project.release.max" },
)
.default(() => {
const defaultDate = new Date();
defaultDate.setDate(defaultDate.getDate() + 28);
+6 -6
View File
@@ -6,27 +6,27 @@ import { authorizationCode } from "../../../auth/grant";
const code = Astro.url.searchParams.get("code");
if (code === null) {
console.error("Visited OAuth2 login callback without code");
return Astro.redirect("/error");
return Astro.redirect("/500");
}
const csrfToken = Astro.url.searchParams.get("state");
if (csrfToken === null) {
console.error("Visited OAuth2 login callback without CSRF token");
return Astro.redirect("/error");
return Astro.redirect("/500");
}
const csrfCookie = Astro.cookies.get("oauth-csrf");
if (!csrfCookie || csrfToken !== csrfCookie.value) {
console.error("The OAuth2 CSRF cookie was not present or mismatched");
return Astro.redirect("/error");
return Astro.redirect("/500");
} else {
Astro.cookies.delete("oauth-csrf");
Astro.cookies.delete("oauth-csrf", { path: "/" });
}
const csrf = new CSRF();
if (!csrf.verify(import.meta.env.OAUTH2_CLIENT_SECRET, csrfToken)) {
console.error(`Could not verify CSRF token: ${csrfToken}`);
return Astro.redirect("/error");
return Astro.redirect("/500");
}
const oauth = await authorizationCode({
@@ -38,7 +38,7 @@ const session = await buildSession(oauth);
setSession(Astro.cookies, session);
const withCallback = Astro.cookies.get("callback");
Astro.cookies.delete("callback");
Astro.cookies.delete("callback", { path: "/" });
return Astro.redirect(withCallback?.value || "/");
---
+8 -3
View File
@@ -4,7 +4,7 @@ import { getSession, isExpired } from "../../../auth/session";
const wantsCallback = Astro.url.searchParams.get("callback");
if (wantsCallback) {
Astro.cookies.delete("callback");
Astro.cookies.delete("callback", { path: "/" });
Astro.cookies.set("callback", wantsCallback, { path: "/" });
}
@@ -23,8 +23,13 @@ oauth2Authorize.searchParams.set("scope", "email");
const csrf = new CSRF();
const csrfToken = csrf.create(import.meta.env.OAUTH2_CLIENT_SECRET);
Astro.cookies.delete("oauth-csrf");
Astro.cookies.set("oauth-csrf", csrfToken);
Astro.cookies.delete("oauth-csrf", { path: "/" });
Astro.cookies.set("oauth-csrf", csrfToken, {
path: "/",
httpOnly: true,
secure: true,
sameSite: "lax",
});
oauth2Authorize.searchParams.set("state", csrfToken);
return Astro.redirect(oauth2Authorize);
@@ -1,238 +0,0 @@
---
import Layout from "../../../layouts/Layout.astro";
import App from "../../../layouts/App.svelte";
import ProfileHero from "../../../components/profile/ProfileHero.astro";
import ProfileInfo from "../../../components/profile/ProfileInfo.svelte";
import Tabs from "../../../components/library/layout/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 { getDefaultCurrency } from "../../../utils/consts";
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("pages.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("pages.profile.tabs.about") },
{ id: "projects", label: t("pages.profile.tabs.projects") },
{ id: "donorType", label: t("pages.profile.tabs.donorType") },
];
// Fetch user's backed/supported projects from API
let userSupports: ProjectSupport[] = [];
let totalDonated: { amount: number; currency: string } = {
amount: 0,
currency: getDefaultCurrency(),
};
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("pages.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("pages.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>
+2 -2
View File
@@ -5,14 +5,14 @@ import RegisterForm from "../../components/auth/RegisterForm.astro";
import AuthSummary from "../../components/auth/AuthSummary.svelte";
import Button from "../../components/library/buttons/Button.svelte";
import BackButton from "../../components/library/buttons/BackButton.svelte";
import { cartCount } from "../../stores/cart";
import { cartCount } from "../../stores/checkoutsStore";
import Summary from "../../components/checkout/Summary.svelte";
const { t, lang, session } = Astro.locals;
let hasCart = false;
cartCount.subscribe((count) => {
hasCart = count > 1;
hasCart = count > 0;
});
---
+10
View File
@@ -60,6 +60,16 @@ export class ProjectsService {
hasNextPage,
};
} catch (error) {
// Re-throw aborted requests as-is so callers can detect cancellation.
// The fetch client may not preserve the DOMException name, so also
// check the signal directly.
if (
options?.abortSignal?.aborted ||
(error instanceof Error && error.name === "AbortError")
) {
throw new DOMException("Aborted", "AbortError");
}
// Re-throw auth errors as-is for component handling
if (error && typeof error === "object" && "type" in error) {
throw error as AuthError;
-186
View File
@@ -1,186 +0,0 @@
import { writable, derived } from "svelte/store";
import { apiProjectRewardsIdGetUrl } from "../openapi/client/paths.gen";
import { getDefaultCurrency } from "../utils/consts";
import { multiplyMoney, sumMoney } from "../utils/money";
import type { GatewayCharge, ProjectReward } from "../openapi/client";
export interface CartItem extends GatewayCharge {
key: string;
kind: "free" | "reward" | "tip";
quantity: number;
/**
* `target` references the Accounting that will receive the money\
* `recipient` references the owner of that Accounting
*/
recipient: string;
recipientDisplayName: string;
/**
* Items of kind "reward" must have an associated ProjectReward
*/
reward?: ProjectReward;
}
type CartState = {
items: Record<string, CartItem>;
};
export interface CartStore {
subscribe: (run: (value: CartState) => void) => () => void;
addItem: (item: Omit<CartItem, "key">) => void;
removeItem: (key: string) => void;
updateQuantity: (key: string, quantity: number) => void;
clear: () => void;
clearTarget: (target: string) => void;
}
const isBrowser = typeof window !== "undefined";
function generateKey(item: Omit<CartItem, "key">): string {
const base = `${item.kind}:${item.recipient}`;
if (item.kind === "reward" && item.reward?.id != null) {
return `${base};reward:${apiProjectRewardsIdGetUrl.replace("{id}", String(item.reward.id))}`;
}
return base;
}
export function parseKey(key: string): {
kind: string;
recipient: string;
extra: { kind: string; recipient: string }[];
} {
const segments = key.split(";");
const colonIdx = segments[0].indexOf(":");
const kind = segments[0].slice(0, colonIdx);
const recipient = segments[0].slice(colonIdx + 1);
const extra = segments.slice(1).map((s) => {
const idx = s.indexOf(":");
return { kind: s.slice(0, idx), recipient: s.slice(idx + 1) };
});
return { kind, recipient, extra };
}
function loadInitialCart(): CartState {
const fresh = { items: {} };
if (!isBrowser) return fresh;
try {
const stored = localStorage.getItem("cart");
if (stored) return JSON.parse(stored);
} catch (e) {
console.warn("⚠️ Error loading cart from localStorage:", e);
}
localStorage.setItem("cart", JSON.stringify(fresh));
return fresh;
}
function createCartStore(): CartStore {
const { subscribe, set, update } = writable<CartState>(loadInitialCart());
subscribe((cart) => {
try {
if (!isBrowser) return;
localStorage.setItem("cart", JSON.stringify(cart));
} catch (e) {
console.error("Error to save cart to localStorage:", e);
}
});
return {
subscribe,
addItem: (item: Omit<CartItem, "key">) =>
update((cart) => {
const key = generateKey(item);
const items = { ...cart.items };
if (item.quantity === 0) {
delete items[key];
return { items };
}
items[key] = { ...item, key } as CartItem;
return { items };
}),
removeItem: (key: string) =>
update((cart) => {
const items = { ...cart.items };
delete items[key];
return { items };
}),
updateQuantity: (key: string, quantity: number) =>
update((cart) => {
const items = { ...cart.items };
if (quantity <= 0) {
delete items[key];
} else if (items[key]) {
items[key] = { ...items[key], quantity };
}
return { items };
}),
clear: () => {
set({ items: {} });
localStorage.removeItem("cart");
},
clearTarget: (target: string) =>
update((cart) => {
const items = Object.fromEntries(
Object.entries(cart.items).filter((item) => item[1].target !== target),
);
return { items };
}),
};
}
export const cart = createCartStore();
export const cartCount = derived(cart, ($cart) =>
Object.values($cart.items).reduce((total, item) => total + item.quantity, 0),
);
export const cartAmount = derived(cart, ($cart) => {
const items = Object.values($cart.items);
if (items.length === 0) return { amount: 0, currency: getDefaultCurrency() };
return sumMoney(items.map((item) => multiplyMoney(item.money, item.quantity)));
});
export const cartByTarget = derived(cart, ($cart) => {
const grouped: Record<string, CartItem[]> = {};
for (const item of Object.values($cart.items)) {
if (item.target != null) {
grouped[item.target] ??= [];
grouped[item.target].push(item);
}
}
return grouped;
});
export const cartByRecipient = derived(cart, ($cart) => {
const grouped: Record<string, CartItem[]> = {};
for (const item of Object.values($cart.items)) {
const { recipient } = parseKey(item.key);
grouped[recipient] ??= [];
grouped[recipient].push(item);
}
return grouped;
});
+12
View File
@@ -37,4 +37,16 @@ export const sortOptions: SortOption[] = [
direction: "asc",
label: "pages.admin.charges.filters.order.options.amount-asc",
},
{
key: "status-asc",
field: "status",
direction: "asc",
label: "pages.admin.charges.filters.order.options.status-asc",
},
{
key: "status-desc",
field: "status",
direction: "desc",
label: "pages.admin.charges.filters.order.options.status-desc",
},
];
+328
View File
@@ -0,0 +1,328 @@
import { derived, get, writable } from "svelte/store";
import { session } from "../auth/store";
import { apiProjectRewardsIdGetUrl } from "../openapi/client/paths.gen";
import { migrateLegacyCart } from "../utils/checkouts/migrate";
import { checkoutRepo } from "../utils/checkouts/repository";
import { getDefaultCurrency } from "../utils/consts";
import { multiplyMoney, sumMoney } from "../utils/money";
import type { GatewayCharge, ProjectReward } from "../openapi/client";
export interface CheckoutItem extends GatewayCharge {
key: string;
kind: "free" | "reward" | "tip";
quantity: number;
/**
* `target` references the Accounting that will receive the money\
* `recipient` references the owner of that Accounting
*/
recipient: string;
recipientDisplayName: string;
/**
* Items of kind "reward" must have an associated ProjectReward
*/
reward?: ProjectReward;
}
/**
* IndexedDB record holding the pending checkout of a single user.
* Keyed by `user:<id>` for authenticated sessions, `user:guest` otherwise.
*/
export interface StoredCheckout {
key: string;
items: Record<string, CheckoutItem>;
updatedAt: number;
}
type CheckoutState = {
items: Record<string, CheckoutItem>;
};
export interface CheckoutStore {
subscribe: (run: (value: CheckoutState) => void) => () => void;
addItem: (item: Omit<CheckoutItem, "key">) => void;
removeItem: (key: string) => void;
updateQuantity: (key: string, quantity: number) => void;
clear: () => void;
clearTarget: (target: string) => void;
clearForUser: (userId?: string | number) => void;
}
const isBrowser = typeof window !== "undefined";
export const GUEST_CHECKOUT_KEY = "user:guest";
export function checkoutKeyForUser(userId?: string | number): string {
return userId != null && userId !== "" ? `user:${userId}` : GUEST_CHECKOUT_KEY;
}
function generateKey(item: Omit<CheckoutItem, "key">): string {
const base = `${item.kind}:${item.recipient}`;
if (item.kind === "reward" && item.reward?.id != null) {
return `${base};reward:${apiProjectRewardsIdGetUrl.replace("{id}", String(item.reward.id))}`;
}
return base;
}
export function parseKey(key: string): {
kind: string;
recipient: string;
extra: { kind: string; recipient: string }[];
} {
const segments = key.split(";");
const colonIdx = segments[0].indexOf(":");
const kind = segments[0].slice(0, colonIdx);
const recipient = segments[0].slice(colonIdx + 1);
const extra = segments.slice(1).map((s) => {
const idx = s.indexOf(":");
return { kind: s.slice(0, idx), recipient: s.slice(idx + 1) };
});
return { kind, recipient, extra };
}
const state = writable<CheckoutState>({ items: {} });
const hydrated = writable(false);
/**
* True once the active user's record has been loaded from IndexedDB
* into the in-memory store.
*/
export const checkoutHydrated = { subscribe: hydrated.subscribe };
let activeKey = GUEST_CHECKOUT_KEY;
let skipHydrationMerge = false;
let queue: Promise<void> = Promise.resolve();
function enqueue(task: () => Promise<void>): Promise<void> {
queue = queue.then(task).catch((e) => {
console.error("Error persisting checkout to IndexedDB:", e);
});
return queue;
}
/**
* Resolves once every persistence operation enqueued so far has settled.
*/
export function checkoutReady(): Promise<void> {
return queue;
}
function schedulePersist() {
if (!isBrowser) return;
const key = activeKey;
enqueue(async () => {
const { items } = get(state);
await checkoutRepo.update(key, { items });
});
}
function removeLegacyStorageKey() {
try {
localStorage.removeItem("cart");
} catch {
// ignore storage access errors
}
}
if (isBrowser) {
enqueue(async () => {
// Snapshot the key: `activeKey` can flip mid-flight when the session
// store resolves (guest → user), and writing the guest-derived items
// back under the user key would wipe their stored checkout.
const key = activeKey;
await migrateLegacyCart();
const record = await checkoutRepo.get(key);
const loaded = record?.items ?? {};
if (!skipHydrationMerge) {
// Mutations made before hydration completes win over stored items
let merged: Record<string, CheckoutItem> = {};
state.update((current) => {
merged = { ...loaded, ...current.items };
return { items: merged };
});
await checkoutRepo.update(key, { items: merged });
}
hydrated.set(true);
});
session.subscribe((currentSession) => {
const nextKey = checkoutKeyForUser(currentSession?.user?.id);
if (nextKey === activeKey) return;
const previousKey = activeKey;
activeKey = nextKey;
enqueue(async () => {
if (previousKey === GUEST_CHECKOUT_KEY && nextKey !== GUEST_CHECKOUT_KEY) {
// Merge the guest checkout into the user's record on login.
// On conflicting item keys the user's stored item wins.
const guest = await checkoutRepo.get(GUEST_CHECKOUT_KEY);
const guestItems = guest?.items ?? {};
if (Object.keys(guestItems).length > 0) {
const userRecord = await checkoutRepo.get(nextKey);
await checkoutRepo.update(nextKey, {
items: { ...guestItems, ...userRecord?.items },
});
}
await checkoutRepo.remove(GUEST_CHECKOUT_KEY);
}
const record = await checkoutRepo.get(nextKey);
state.set({ items: record?.items ?? {} });
});
});
const resyncActiveRecord = () => {
if (!get(hydrated)) return;
const key = activeKey;
enqueue(async () => {
const record = await checkoutRepo.get(key);
state.set({ items: record?.items ?? {} });
});
};
window.addEventListener("pageshow", (event) => {
if (event.persisted) resyncActiveRecord();
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") resyncActiveRecord();
});
}
export const cart: CheckoutStore = {
subscribe: state.subscribe,
addItem: (item: Omit<CheckoutItem, "key">) => {
state.update((current) => {
const key = generateKey(item);
const items = { ...current.items };
if (item.quantity === 0) {
delete items[key];
return { items };
}
items[key] = { ...item, key } as CheckoutItem;
return { items };
});
schedulePersist();
},
removeItem: (key: string) => {
state.update((current) => {
const items = { ...current.items };
delete items[key];
return { items };
});
schedulePersist();
},
updateQuantity: (key: string, quantity: number) => {
state.update((current) => {
const items = { ...current.items };
if (quantity <= 0) {
delete items[key];
} else if (items[key]) {
items[key] = { ...items[key], quantity };
}
return { items };
});
schedulePersist();
},
clear: () => {
skipHydrationMerge = true;
state.set({ items: {} });
if (!isBrowser) return;
const key = activeKey;
enqueue(() => checkoutRepo.remove(key));
removeLegacyStorageKey();
},
clearTarget: (target: string) => {
state.update((current) => {
const items = Object.fromEntries(
Object.entries(current.items).filter((item) => item[1].target !== target),
);
return { items };
});
schedulePersist();
},
clearForUser: (userId?: string | number) => {
skipHydrationMerge = true;
state.set({ items: {} });
if (!isBrowser) return;
enqueue(async () => {
await checkoutRepo.remove(GUEST_CHECKOUT_KEY);
const key = checkoutKeyForUser(userId);
if (key !== GUEST_CHECKOUT_KEY) {
await checkoutRepo.remove(key);
}
});
removeLegacyStorageKey();
},
};
/**
* Standalone export so Astro inline scripts can clear without pulling in the store object.
*/
export const clearForUser = cart.clearForUser;
export const cartCount = derived(cart, ($cart) =>
Object.values($cart.items).reduce((total, item) => total + item.quantity, 0),
);
export const cartAmount = derived(cart, ($cart) => {
const items = Object.values($cart.items);
if (items.length === 0) return { amount: 0, currency: getDefaultCurrency() };
return sumMoney(items.map((item) => multiplyMoney(item.money, item.quantity)));
});
export const cartByTarget = derived(cart, ($cart) => {
const grouped: Record<string, CheckoutItem[]> = {};
for (const item of Object.values($cart.items)) {
if (item.target != null) {
grouped[item.target] ??= [];
grouped[item.target].push(item);
}
}
return grouped;
});
export const cartByRecipient = derived(cart, ($cart) => {
const grouped: Record<string, CheckoutItem[]> = {};
for (const item of Object.values($cart.items)) {
const { recipient } = parseKey(item.key);
grouped[recipient] ??= [];
grouped[recipient].push(item);
}
return grouped;
});
+23 -13
View File
@@ -20,6 +20,7 @@ export interface SearchState {
itemsPerPage: number;
hasNextPage: boolean;
hasPrevPage: boolean;
isLoadingMore: boolean;
currentAbortController?: AbortController;
}
@@ -31,7 +32,7 @@ function getInitialState(): SearchState {
return {
filters: {
page: 1,
itemsPerPage: 20,
itemsPerPage: 18,
},
results: [],
isLoading: false,
@@ -42,9 +43,10 @@ function getInitialState(): SearchState {
lastSearchTime: 0,
// Pagination state
currentPage: 1,
itemsPerPage: 20,
itemsPerPage: 18,
hasNextPage: false,
hasPrevPage: false,
isLoadingMore: false,
currentAbortController: undefined,
};
}
@@ -102,6 +104,7 @@ function createSearchStore() {
update((state) => ({
...state,
isLoading: true,
isLoadingMore: false,
hasError: false,
errorMessage: "",
currentAbortController: abortController,
@@ -130,17 +133,18 @@ function createSearchStore() {
}));
}
} catch (error) {
console.error(error);
// Only set error if request wasn't cancelled
if (!(error instanceof Error) || error.name !== "AbortError") {
update((state) => ({
...state,
isLoading: false,
hasError: true,
errorMessage: error instanceof Error ? error.message : "Search failed",
currentAbortController: undefined,
}));
// Ignore cancelled requests — a new search aborted this one
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error(error);
update((state) => ({
...state,
isLoading: false,
hasError: true,
errorMessage: error instanceof Error ? error.message : "Search failed",
currentAbortController: undefined,
}));
}
};
@@ -225,7 +229,7 @@ function createSearchStore() {
...state,
filters: {
page: 1,
itemsPerPage: 20,
itemsPerPage: 18,
},
})),
@@ -386,6 +390,7 @@ function createSearchStore() {
update((state) => ({
...state,
isLoading: true,
isLoadingMore: true,
hasError: false,
errorMessage: "",
currentAbortController: abortController,
@@ -409,6 +414,7 @@ function createSearchStore() {
hasNextPage: response.hasNextPage,
hasPrevPage,
isLoading: false,
isLoadingMore: false,
hasSearched: true,
lastSearchTime: Date.now(),
currentAbortController: undefined,
@@ -421,6 +427,7 @@ function createSearchStore() {
update((state) => ({
...state,
isLoading: false,
isLoadingMore: false,
hasError: true,
errorMessage:
error instanceof Error ? error.message : "Failed to load more results",
@@ -446,6 +453,9 @@ export const searchResults = derived(searchStore, ($searchStore) => $searchStore
export const isSearching = derived(searchStore, ($searchStore) => $searchStore.isLoading);
// True only while appending more results (Load more) — not during a fresh search
export const isLoadingMore = derived(searchStore, ($searchStore) => $searchStore.isLoadingMore);
export const searchError = derived(searchStore, ($searchStore) =>
$searchStore.hasError ? $searchStore.errorMessage : null,
);
+35
View File
@@ -0,0 +1,35 @@
/**
* Parses a recurring DD-MM string into the next upcoming local-midnight Date.
* The year is implicit: the cutoff recurs every year, so the value resolves to
* the next occurrence of that day/month on or after today.
* Returns undefined for empty or malformed values.
*/
export function parseMaxEndDate(raw: string | undefined, now: Date = new Date()): Date | undefined {
if (!raw || raw === "") return undefined;
const match = /^(\d{2})-(\d{2})$/.exec(raw);
if (!match) {
console.warn(`Invalid PUBLIC_CAMPAIGN_MAX_END_DATE value (expected DD-MM): ${raw}`);
return undefined;
}
const day = Number(match[1]);
const month = Number(match[2]);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const cutoff = new Date(today.getFullYear(), month - 1, day);
// Already passed this year → use next year's occurrence
if (cutoff < today) {
cutoff.setFullYear(cutoff.getFullYear() + 1);
}
return cutoff;
}
/**
* Latest date a project may start its campaign, as configured by the platform
* owner. PUBLIC_CAMPAIGN_MAX_END_DATE is a recurring DD-MM value (year implicit);
* it resolves to the next upcoming occurrence. Leave empty to disable the limit.
*/
export const maxEndDate = parseMaxEndDate(import.meta.env.PUBLIC_CAMPAIGN_MAX_END_DATE);
+11
View File
@@ -0,0 +1,11 @@
import { Dexie, type EntityTable } from "dexie";
import type { StoredCheckout } from "../../stores/checkoutsStore";
export const db = new Dexie("goteo-checkouts") as Dexie & {
checkouts: EntityTable<StoredCheckout, "key">;
};
db.version(1).stores({
checkouts: "key",
});
+49
View File
@@ -0,0 +1,49 @@
import { checkoutRepo } from "./repository";
import type { CheckoutItem } from "../../stores/checkoutsStore";
const LEGACY_STORAGE_KEY = "cart";
const GUEST_KEY = "user:guest";
/**
* One-time migration of the legacy localStorage cart blob into the guest
* IndexedDB record. The old store re-seeded the "cart" key on every page
* load, so the key is removed unconditionally — even on unparseable data —
* to guarantee the migration runs at most once per browser. Never throws.
*/
export async function migrateLegacyCart(): Promise<void> {
if (typeof window === "undefined") return;
let raw: string | null = null;
try {
raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (raw === null) return;
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch {
return;
}
try {
const parsed = JSON.parse(raw) as { items?: Record<string, CheckoutItem> };
const legacyItems = Object.fromEntries(
Object.entries(parsed?.items ?? {}).filter(
([key, item]) =>
typeof key === "string" &&
item != null &&
typeof item.quantity === "number" &&
item.quantity > 0,
),
);
if (Object.keys(legacyItems).length === 0) return;
// An existing guest record is newer than the legacy blob — it wins on conflicts
const existing = await checkoutRepo.get(GUEST_KEY);
await checkoutRepo.update(GUEST_KEY, {
items: { ...legacyItems, ...existing?.items },
});
} catch (e) {
console.warn("⚠️ Error migrating legacy cart from localStorage:", e);
}
}
+51
View File
@@ -0,0 +1,51 @@
import { db } from "./db";
import type { StoredCheckout } from "../../stores/checkoutsStore";
const isBrowser = typeof window !== "undefined";
/**
* IndexedDB stores via structured clone, which throws DataCloneError on
* Svelte 5 $state proxies (e.g. items built from $bindable props).
* A JSON round-trip strips proxies down to plain clonable objects.
*/
function toPlain<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export const checkoutRepo = {
async create(record: StoredCheckout) {
if (!isBrowser) return record;
await db.checkouts.put(toPlain({ ...record, updatedAt: Date.now() }));
return record;
},
async get(key: string) {
if (!isBrowser) return undefined;
return db.checkouts.get(key);
},
async update(key: string, data: Partial<StoredCheckout>) {
if (!isBrowser) return;
const existing = await db.checkouts.get(key);
await db.checkouts.put(
toPlain({
key,
items: {},
...existing,
...data,
updatedAt: Date.now(),
}),
);
},
async remove(key: string) {
if (!isBrowser) return;
await db.checkouts.delete(key);
},
};
+263
View File
@@ -0,0 +1,263 @@
import { get } from "svelte/store";
import { getDefaultLanguage } from "./consts";
import { currencySymbols } from "./currencyData";
import {
suggestGateways,
suggestTarget,
suggestOwner,
suggestCategories,
suggestProjects,
suggestProjectsBySubtitle,
suggestProjectsByDescription,
suggestProjectsBySlug,
} from "./filterSuggestions";
import { locale } from "../i18n/store";
export interface FilterRow {
id: string;
subject: FilterSubject;
operator: FilterOperator;
referent: string | number | Date | string[];
serialize: () => Record<string, string | string[]>;
}
export type FilterOperator =
| "equals"
| "is_any_of"
| "before"
| "after"
| "strictly_before"
| "strictly_after"
| "gte"
| "gt"
| "lte"
| "lt";
export type FilterResource = "project" | "gateway_charge";
export interface FilterOption {
value: string;
label: string;
}
export interface FilterSubject {
key: string;
param?: string;
type: "string" | "date" | "number";
compatibleOperators: FilterOperator[];
resources: FilterResource[];
options?: FilterOption[];
suggest?: (q: string) => Promise<FilterOption[]>;
}
const gatewayChargeStatuses: FilterOption[] = [
{ value: "to_charge", label: "To charge" },
{ value: "in_charge", label: "In charge" },
{ value: "to_refund", label: "To refund" },
{ value: "refunded", label: "Refunded" },
{ value: "to_wallet", label: "To wallet" },
{ value: "walleted", label: "Wallet" },
];
const chargeTypes: FilterOption[] = [
{ value: "single", label: "Single" },
{ value: "recurring", label: "Recurring" },
];
const projectStatuses: FilterOption[] = [
{ value: "in_draft", label: "In draft" },
{ value: "to_campaign_review", label: "To campaign review" },
{ value: "in_campaign_review", label: "In campaign review" },
{ value: "in_campaign_review.request_change", label: "Request change (campaign)" },
{ value: "campaign_review.rejected", label: "Rejected (campaign)" },
{ value: "to_campaign", label: "To campaign" },
{ value: "in_campaign", label: "In campaign" },
{ value: "campaign.failed", label: "Failed (campaign)" },
{ value: "to_funding_review", label: "To funding review" },
{ value: "in_funding_review", label: "In funding review" },
{ value: "in_funding_review.request_change", label: "Request change (funding)" },
{ value: "funding_review.rejected", label: "Rejected (funding)" },
{ value: "to_funding", label: "To funding" },
{ value: "in_funding", label: "In funding" },
{ value: "funding.paid", label: "Funding paid" },
];
const currentLocale = get(locale) || getDefaultLanguage();
const currencyName = new Intl.DisplayNames([currentLocale], { type: "currency" });
const currencies: FilterOption[] = Object.keys(currencySymbols).map((code) => ({
value: code,
label: `${currencyName.of(code)} (${code})`,
}));
const filterSubjects: Record<string, FilterSubject> = {
title: {
key: "title",
type: "string",
compatibleOperators: ["equals"],
resources: ["project"],
suggest: suggestProjects,
},
subtitle: {
key: "subtitle",
type: "string",
compatibleOperators: ["equals"],
resources: ["project"],
suggest: suggestProjectsBySubtitle,
},
description: {
key: "description",
type: "string",
compatibleOperators: ["equals"],
resources: ["project"],
suggest: suggestProjectsByDescription,
},
projectStatus: {
key: "projectStatus",
param: "status",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["project"],
options: projectStatuses,
},
chargeStatus: {
key: "chargeStatus",
param: "status",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["gateway_charge"],
options: gatewayChargeStatuses,
},
categories: {
key: "categories",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["project"],
suggest: suggestCategories,
},
owner: {
key: "owner",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["project"],
suggest: suggestOwner,
},
slug: {
key: "slug",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["project"],
suggest: suggestProjectsBySlug,
},
territoryCountry: {
key: "territoryCountry",
param: "territory.country",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["project"],
},
dateUpdated: {
key: "dateUpdated",
type: "date",
compatibleOperators: ["before", "after", "strictly_before", "strictly_after"],
resources: ["project", "gateway_charge"],
},
budgetAmount: {
key: "budgetAmount",
type: "number",
compatibleOperators: ["gte", "gt", "lte", "lt"],
resources: ["project"],
},
gateway: {
key: "gateway",
param: "checkout.gateway",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["gateway_charge"],
suggest: suggestGateways,
},
type: {
key: "type",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["gateway_charge"],
options: chargeTypes,
},
target: {
key: "target",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["gateway_charge"],
suggest: suggestTarget,
},
currency: {
key: "currency",
param: "money.currency",
type: "string",
compatibleOperators: ["equals", "is_any_of"],
resources: ["gateway_charge"],
options: currencies,
},
amount: {
key: "amount",
param: "money.amount",
type: "number",
compatibleOperators: ["gte", "gt", "lte", "lt"],
resources: ["gateway_charge"],
},
dateCreated: {
key: "dateCreated",
type: "date",
compatibleOperators: ["before", "after", "strictly_before", "strictly_after"],
resources: ["gateway_charge"],
},
};
export function createFilterRow(
subject: FilterSubject,
operator: FilterOperator,
referent: string | number | Date | string[],
): FilterRow {
const id = crypto.randomUUID();
return {
id,
subject,
operator,
referent,
serialize: () => {
const key = subject.param ?? subject.key;
switch (operator) {
case "equals":
return { [key]: String(referent) };
case "is_any_of":
return { [`${key}[]`]: referent as string[] };
case "before":
case "after":
case "strictly_before":
case "strictly_after": {
const date = referent instanceof Date ? referent : new Date(referent as string);
return { [`${key}[${operator}]`]: date.toISOString() };
}
case "gte":
case "gt":
case "lte":
case "lt":
return { [`${key}[${operator}]`]: String(referent) };
default:
return { [key]: String(referent) };
}
},
};
}
export function getFilterSubject(key: string): FilterSubject | undefined {
return filterSubjects[key];
}
export function getAllFilterSubjects(resource?: FilterResource): FilterSubject[] {
const all = Object.values(filterSubjects);
return resource ? all.filter((s) => s.resources.includes(resource)) : all;
}
+124
View File
@@ -0,0 +1,124 @@
import {
apiGatewaysGetCollection,
apiProjectsGetCollection,
apiTipjarsGetCollection,
apiUsersGetCollection,
apiCategoriesGetCollection,
} from "../openapi/client";
import type { FilterOption } from "./filterComposer";
type CollectionResponse<T> = { totalItems: number; member: T[] };
export async function suggestGateways(q: string): Promise<FilterOption[]> {
const { data } = await apiGatewaysGetCollection({
headers: { Accept: "application/ld+json" },
});
if (!data) return [];
const gateways = (data as unknown as CollectionResponse<Record<string, unknown>>).member ?? [];
return gateways
.filter((g) => !q || ((g.name as string) ?? "").toLowerCase().includes(q.toLowerCase()))
.map((g) => ({
value: g.id as string,
label: g.name as string,
}));
}
export async function suggestTarget(q: string): Promise<FilterOption[]> {
const trimmed = q.trim();
if (trimmed.length < 2) return [];
const [projects, tipjars, users] = await Promise.all([
apiProjectsGetCollection({ query: { title: trimmed } }),
apiTipjarsGetCollection({ query: { name: trimmed } }),
apiUsersGetCollection({ query: { handle: trimmed } }),
]);
const options: FilterOption[] = [];
for (const p of projects.data ?? []) {
options.push({ value: p.accounting ?? "", label: p.title ?? "" });
}
for (const t of tipjars.data ?? []) {
options.push({ value: t.accounting ?? "", label: t.name ?? "" });
}
for (const u of users.data ?? []) {
options.push({ value: u.handle ?? "", label: u.handle ?? "" });
}
return options;
}
export async function suggestOwner(q: string): Promise<FilterOption[]> {
const trimmed = q.trim();
if (trimmed.length < 2) return [];
const { data } = await apiUsersGetCollection({ query: { handle: trimmed } });
if (!data) return [];
return data.map((u) => ({
value: u.handle ?? "",
label: u.handle ?? "",
}));
}
export async function suggestCategories(q: string): Promise<FilterOption[]> {
const { data } = await apiCategoriesGetCollection();
if (!data) return [];
return data
.filter((c) => !q || c.name?.toLowerCase().includes(q.toLowerCase()))
.map((c) => ({ value: c.id ?? "", label: c.name ?? "" }));
}
export async function suggestProjects(q: string): Promise<FilterOption[]> {
const trimmed = q.trim();
if (trimmed.length < 2) return [];
const { data } = await apiProjectsGetCollection({ query: { title: trimmed } });
if (!data) return [];
return data.map((p) => ({
value: p.slug ?? "",
label: p.title ?? p.slug ?? "",
}));
}
export async function suggestProjectsBySubtitle(q: string): Promise<FilterOption[]> {
const trimmed = q.trim();
if (trimmed.length < 2) return [];
const { data } = await apiProjectsGetCollection({ query: { subtitle: trimmed } });
if (!data) return [];
return data.map((p) => ({
value: p.slug ?? "",
label: p.title ?? p.slug ?? "",
}));
}
export async function suggestProjectsByDescription(q: string): Promise<FilterOption[]> {
const trimmed = q.trim();
if (trimmed.length < 2) return [];
const { data } = await apiProjectsGetCollection({ query: { description: trimmed } });
if (!data) return [];
return data.map((p) => ({
value: p.slug ?? "",
label: p.title ?? p.slug ?? "",
}));
}
export async function suggestProjectsBySlug(q: string): Promise<FilterOption[]> {
const trimmed = q.trim();
if (trimmed.length < 2) return [];
const { data } = await apiProjectsGetCollection({ query: { slug: trimmed } });
if (!data) return [];
return data.map((p) => ({
value: p.slug ?? "",
label: p.title ?? p.slug ?? "",
}));
}
+2 -1
View File
@@ -19,7 +19,8 @@ const defaultOptions: RedirectOptions = {
export function goto(target: URL | string, options: RedirectOptions = defaultOptions): Response {
const status = options.type === "permanent" ? 301 : 302;
const query = options.query ? "?" + options.query.toString() : "";
const params = options.query ? new URLSearchParams(options.query).toString() : "";
const query = params ? `?${params}` : "";
return new Response(null, { status, headers: { Location: target + query } });
}