Merge branch 'develop' into fix/project-edit-rewards-and-budget
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user