Merge remote-tracking branch 'origin/develop' into chore/update-astro
# Conflicts: # pnpm-lock.yaml # src/components/create/CreateProjectForm.svelte
This commit is contained in:
@@ -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=""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,6 +39,7 @@ export default [
|
||||
".github/**",
|
||||
"env.d.ts",
|
||||
"storybook-static/**",
|
||||
"graphify-out/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+281
-245
File diff suppressed because it is too large
Load Diff
+15
-13
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
{#each project.categories as category}
|
||||
<CategoryTag iri={category} />
|
||||
{/each}
|
||||
<TerritoryTag territory={project.territory} />
|
||||
<TerritoryTag territory={project.territory} iconSize="24" />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1324
-1058
File diff suppressed because it is too large
Load Diff
+175
-27
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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 || "/");
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user