Merge branch 'develop' into fix/project-edit-rewards-and-budget

This commit is contained in:
2026-09-03 13:29:33 +02:00
9 changed files with 162 additions and 17 deletions
@@ -2,7 +2,7 @@
import { actions } from "astro:actions";
import { locale, t } from "../../../../i18n/store";
import { formatDate } from "../../../../utils/dates";
import { endOfDay, formatDate, startOfDay } from "../../../../utils/dates";
import ActionableButton from "../../../library/buttons/ActionableButton.svelte";
import DateInput from "../../../library/inputs/DateInput.svelte";
import TextArea from "../../../library/inputs/TextArea.svelte";
@@ -23,6 +23,32 @@
let fieldErrors: FieldErrors = $state({});
let startsAt = $state(new Date());
let endsAt = $state(new Date());
/**
* Normalize a date field to the human expected true datetime
* @param field
* @param date
*/
function normalizeDate(field: "startsAt" | "endsAt", date: Date): Date {
const now = new Date();
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
// Ends always run to the end of their day. Start dates run from their
// midnight, except when the day is today the current time is used so it
// passes the "no earlier than now" validation. DateInput binds the same
// object, so the normalized time is applied in place.
const normalized = field === "endsAt" ? endOfDay(date) : isToday ? now : startOfDay(date);
date.setTime(normalized.getTime());
return date;
}
async function submit() {
fieldErrors = {};
@@ -100,19 +126,23 @@
<div class="flex gap-6">
<DateInput
bind:value={startsAt}
class="flex-1"
name="startsAt"
placeholder={$t("pages.admin.comm.banners.fields.startDatePlaceholder")}
error={fieldErrors.startsAt &&
$t(fieldErrors.startsAt, { date: formatDate(new Date(), $locale) })}
onInput={(date) => (startsAt = normalizeDate("startsAt", date))}
/>
<DateInput
bind:value={endsAt}
class="flex-1"
name="endsAt"
placeholder={$t("pages.admin.comm.banners.fields.endDatePlaceholder")}
error={fieldErrors.endsAt &&
$t(fieldErrors.endsAt, { date: formatDate(new Date(), $locale) })}
onInput={(date) => (endsAt = normalizeDate("endsAt", date))}
/>
</div>
</div>
+2 -2
View File
@@ -18,7 +18,7 @@
function handleClose() {
visible = false;
if (onClose) onClose();
onClose?.();
}
</script>
@@ -67,7 +67,7 @@
<button
onclick={handleClose}
aria-label={closeAriaLabel}
class="flex h-12 w-12 items-center justify-center rounded-full transition-colors hover:bg-white/10"
class="flex size-12 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-white/10"
>
<Close class="text-purple-soft h-6 w-6" />
</button>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from "svelte";
import HomeBanner from "./HomeBanner.svelte";
import { t } from "../../i18n/store";
import { getCookie, setCookie } from "../../utils/cookies";
import type { BannerRecord } from "../../repositories/banners";
const CLOSED_BANNERS_COOKIE = "goteo-banners-closed";
interface Props {
banners: BannerRecord[];
}
let { banners }: Props = $props();
let closed = $state<string[]>([]);
function getClosed() {
return (getCookie(CLOSED_BANNERS_COOKIE) ?? "").split(",").filter(Boolean);
}
onMount(() => {
closed = getClosed();
});
$effect(() => {
closed = getClosed();
});
let visibleBanners = $derived(banners.filter((banner) => !closed.includes(String(banner.id))));
function handleClose(id: number) {
if (closed.includes(String(id))) return;
closed = [...closed, String(id)];
setCookie(CLOSED_BANNERS_COOKIE, closed.join(","));
}
</script>
{#if visibleBanners.length > 0}
<section class="wrapper flex flex-col gap-4">
{#each visibleBanners as banner (banner.id)}
<HomeBanner
title={banner.title}
description={banner.content}
ctaText={banner.ctaText}
ctaLink={banner.ctaLink}
closeAriaLabel={$t("pages.home.banner.closeAriaLabel")}
onClose={() => handleClose(banner.id)}
/>
{/each}
</section>
{/if}
+4
View File
@@ -1775,6 +1775,10 @@
"modal": {
"title": "Anunci publicat el {{date}}",
"description": "Aquí pots veure com era l'anunci"
},
"deleteModal": {
"title": "Voleu suprimir aquest anunci?",
"description": "L'anunci se suprimirà permanentment i desapareixerà de l'historial. Aquesta acció no es pot desfer."
}
}
}
+4
View File
@@ -1775,6 +1775,10 @@
"modal": {
"title": "Announcement published on {{date}}",
"description": "Here you can see what the announcement looked like"
},
"deleteModal": {
"title": "Delete this announcement?",
"description": "The announcement will be permanently deleted and disappear from the history. This action cannot be undone."
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
"expectedUrl": "No es una URL"
},
"date": {
"greaterThan": "La fecha debe ser mayor que {{ date }}"
"greaterThan": "La fecha debe ser mayor que el {{ date }}"
},
"file": {
"maxAllowedSize": "Tamaño máximo permitido {{ size }}",
+5 -11
View File
@@ -8,11 +8,12 @@ import Stats from "../../components/home/Stats.astro";
import FeaturedPromoters from "../../components/home/FeaturedPromoters.astro";
import Blog from "../../components/home/Blog.astro";
import Matchfunding from "../../components/home/Matchfunding.astro";
import HomeBanner from "../../components/home/HomeBanner.svelte";
import Banners from "../../components/home/HomeBanners.svelte";
// Disabled sections, re-enable the import along with the component below:
// import Stories from "../../components/home/Stories.svelte";
// import Resources from "../../components/home/Resources.astro";
import { isSupportedLocale } from "../../i18n/locales";
import { bannerRepository } from "../../repositories/banners";
const { locale } = Astro.params;
@@ -21,21 +22,14 @@ if (locale && !isSupportedLocale(locale)) {
}
const { t, lang } = Astro.locals;
const activeBanners = await bannerRepository.getActive();
---
<Layout title={t("pages.index.title")} description={t("pages.metadata.description")}>
<App client:load locale={lang} class="mb-20 gap-18">
<Hero />
<div class="wrapper">
<HomeBanner
title={t("pages.home.banner.title")}
description={t("pages.home.banner.description")}
ctaText={t("pages.home.banner.ctaText")}
ctaLink={"/about"}
closeAriaLabel={t("pages.home.banner.closeAriaLabel")}
client:load
/>
</div>
<Banners banners={activeBanners} client:load />
<Campaigns />
<Blog />
<Stats />
+39 -2
View File
@@ -34,6 +34,37 @@ class BannerRepository {
ORDER BY date_created DESC`,
)
.all<BannerRecord>()
.then((data) =>
data.results.map((r) => {
return {
...r,
startsAt: new Date(r.startsAt),
endsAt: new Date(r.endsAt),
dateCreated: new Date(r.dateCreated),
};
}),
);
}
public async getActive(): Promise<BannerRecord[]> {
const now = Date.now();
return await this.db
.prepare(
`SELECT
id,
title,
content,
cta_text AS ctaText,
cta_link AS ctaLink,
starts_at AS startsAt,
ends_at AS endsAt,
date_created AS dateCreated
FROM banners
WHERE starts_at <= ? AND ends_at >= ?
ORDER BY date_created DESC`,
)
.bind(now, now)
.all<BannerRecord>()
.then((data) =>
data.results.map((r) => ({
...r,
@@ -44,8 +75,8 @@ class BannerRepository {
);
}
public async create(banner: Omit<BannerRecord, "id">): Promise<void> {
await this.db
public async create(banner: Omit<BannerRecord, "id">): Promise<Omit<BannerRecord, "id">> {
const result = await this.db
.prepare(
`INSERT INTO banners (title, content, cta_text, cta_link, starts_at, ends_at, date_created)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
@@ -60,6 +91,12 @@ class BannerRepository {
banner.dateCreated.getTime(),
)
.run();
if (result.error) {
console.error(result);
}
return banner;
}
public async delete(id: number): Promise<void> {
+21
View File
@@ -10,6 +10,27 @@ export function formatDate(date: Date, locale: Locale) {
return date.toLocaleDateString(locale, options);
}
/**
* The first instant of the day for the given date (midnight, 00:00).
* @param date The date to normalize
* @returns A new Date set to the start of the given day
*/
export function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/**
* The last instant of the day for the given date (23:59:59.999).
* Derived from the start of the following day rather than hardcoding the end-of-day time.
* @param date The date to normalize
* @returns A new Date set to the end of the given day
*/
export function endOfDay(date: Date): Date {
// Start of the next day, minus a single millisecond
const nextDay = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
return new Date(nextDay.getTime() - 1);
}
/**
* Get a date `n` ms from now
* @param offset Milliseconds since now until future date