diff --git a/src/pages/[...locale]/checkout/verify.astro b/src/pages/[...locale]/checkout/verify.astro
index 8dfe042f..87f3afb1 100644
--- a/src/pages/[...locale]/checkout/verify.astro
+++ b/src/pages/[...locale]/checkout/verify.astro
@@ -18,7 +18,13 @@ import { extractId } from "../../../utils/extractId";
import { sumMoney } from "../../../utils/money";
import { goto } from "../../../utils/navigation";
-import type { Money, MoneyOutput, Project, ProjectSupport } from "../../../openapi/client";
+import type {
+ GatewayCharge,
+ Money,
+ MoneyOutput,
+ Project,
+ ProjectSupport,
+} from "../../../openapi/client";
const { t, lang, session } = Astro.locals;
@@ -50,6 +56,7 @@ let total: Money | undefined;
let selectedGateway: string | undefined;
let donatedProjects: Project[] = [];
let projectSupports: ProjectSupport[] = [];
+let charges: GatewayCharge[] = [];
try {
if (!checkoutId) {
@@ -71,7 +78,7 @@ try {
selectedGateway = checkout.gateway.split("/").at(-1);
- const charges = checkout.charges ?? [];
+ charges = checkout.charges ?? [];
const money = charges
.map((charge) => charge.money)
@@ -138,6 +145,7 @@ try {
donatedProjects={donatedProjects}
projectSupports={projectSupports}
postPaymentUrl={postPaymentUrl}
+ {checkoutId}
/>
)
}
diff --git a/src/pages/[...locale]/index.astro b/src/pages/[...locale]/index.astro
index a956c1cf..9632e1fd 100644
--- a/src/pages/[...locale]/index.astro
+++ b/src/pages/[...locale]/index.astro
@@ -26,23 +26,23 @@ if (locale && !isSupportedLocale(locale)) {
const { t, lang } = Astro.locals;
const activeBanners = await bannerRepository.getActive();
-const hero = await homeHeroRepository.getActive();
+const hero = await homeHeroRepository.getActive(lang);
// Authored in the admin as Markdown. Only ROLE_ADMIN can write it.
const heroContent = hero ? await renderMarkdown(hero.content) : null;
---
-
+
-
-
-
+
+
+
-
+
diff --git a/src/pages/[...locale]/login/index.astro b/src/pages/[...locale]/login/index.astro
index 89aac93f..69619cb8 100644
--- a/src/pages/[...locale]/login/index.astro
+++ b/src/pages/[...locale]/login/index.astro
@@ -1,6 +1,6 @@
---
import CSRF from "csrf";
-import { getSession, isExpired } from "../../../auth/session";
+import { isExpired } from "../../../auth/session";
const wantsCallback = Astro.url.searchParams.get("callback");
if (wantsCallback) {
@@ -8,7 +8,7 @@ if (wantsCallback) {
Astro.cookies.set("callback", wantsCallback, { path: "/" });
}
-const session = await getSession(Astro.cookies);
+const { session } = Astro.locals;
if (session && !isExpired(session)) {
return Astro.redirect(wantsCallback || "/");
}
diff --git a/src/pages/[...locale]/me/index.astro b/src/pages/[...locale]/me/index.astro
index 7364d5ba..aabc24e1 100644
--- a/src/pages/[...locale]/me/index.astro
+++ b/src/pages/[...locale]/me/index.astro
@@ -22,7 +22,7 @@ const period = Astro.url.searchParams.get("period") || new Date().getFullYear().
---
-
+
@@ -38,7 +38,7 @@ const period = Astro.url.searchParams.get("period") || new Date().getFullYear().
-
+
diff --git a/src/pages/[...locale]/me/manage/index.astro b/src/pages/[...locale]/me/manage/index.astro
index d3e249b7..3cad5261 100644
--- a/src/pages/[...locale]/me/manage/index.astro
+++ b/src/pages/[...locale]/me/manage/index.astro
@@ -2,21 +2,40 @@
import Layout from "../../../../layouts/Layout.astro";
import App from "../../../../layouts/App.svelte";
import WithSidebar from "../../../../layouts/WithSidebar.svelte";
+import ManagePersonalProfile from "../../../../components/profile/manage/ManagePersonalProfile.svelte";
+import { PROFILE_NAV_ITEMS, PROFILE_PATHS } from "../../../../components/profile/meSections";
+import { setSession } from "../../../../auth/session";
+import { apiUsersIdOrHandleGet, apiUsersIdpersonGet } from "../../../../openapi/client";
import { goto } from "../../../../utils/navigation";
const { lang, t, session } = Astro.locals;
if (!session) {
- return goto("/login", { query: { callback: "/me/manage" } });
+ return goto("/login", { query: { callback: PROFILE_PATHS.root } });
}
-const activePath = "/me/manage";
+const activePath = PROFILE_PATHS.root;
+const navItems = PROFILE_NAV_ITEMS;
+
+const path = { id: String(session.user.id) };
+const headers = session.token.asHttpHeaders;
+
+// The session cookie holds a snapshot of the User taken at login, read the current one
+const [{ data: user }, { data: person }] = await Promise.all([
+ apiUsersIdOrHandleGet({ path: { idOrHandle: path.id }, headers }),
+ apiUsersIdpersonGet({ path, headers }),
+]);
+
+// Refresh the cookie too: the names feed the display name shown in the header
+if (user) {
+ setSession(Astro.cookies, { ...session, user });
+}
---
-
-
-
-
+
+
+
+
diff --git a/src/pages/[...locale]/me/manage/preferences.astro b/src/pages/[...locale]/me/manage/preferences.astro
new file mode 100644
index 00000000..8061763f
--- /dev/null
+++ b/src/pages/[...locale]/me/manage/preferences.astro
@@ -0,0 +1,38 @@
+---
+import Layout from "../../../../layouts/Layout.astro";
+import App from "../../../../layouts/App.svelte";
+import WithSidebar from "../../../../layouts/WithSidebar.svelte";
+import ManagePreferencesSection from "../../../../components/profile/ManagePreferencesSection.svelte";
+import { PROFILE_NAV_ITEMS, PROFILE_PATHS } from "../../../../components/profile/meSections";
+import { apiCategoriesGetCollection } from "../../../../openapi/client";
+import { withoutCache } from "../../../../openapi/cacheInterceptor";
+import { toCollectionItems } from "../../../../utils/hydra";
+import { goto } from "../../../../utils/navigation";
+
+import type { Category } from "../../../../openapi/client";
+
+const { lang, langs, t, session } = Astro.locals;
+
+if (!session) {
+ return goto("/login", { query: { callback: PROFILE_PATHS.preferences } });
+}
+
+const activePath = PROFILE_PATHS.preferences;
+const navItems = PROFILE_NAV_ITEMS;
+
+// The Categories behind "Proyectos que te motivan", already translated by `Accept-Language`
+const { data: categoriesRaw } = await withoutCache(() =>
+ apiCategoriesGetCollection({
+ headers: { "Accept-Language": langs },
+ }),
+);
+const categories = toCollectionItems(categoriesRaw);
+---
+
+
+
+
+
+
+
+
diff --git a/src/pages/[...locale]/me/manage/profile/index.astro b/src/pages/[...locale]/me/manage/profile/index.astro
new file mode 100644
index 00000000..5c56c02d
--- /dev/null
+++ b/src/pages/[...locale]/me/manage/profile/index.astro
@@ -0,0 +1,49 @@
+---
+import Layout from "../../../../../layouts/Layout.astro";
+import App from "../../../../../layouts/App.svelte";
+import WithSidebar from "../../../../../layouts/WithSidebar.svelte";
+import ManagePublicProfile from "../../../../../components/profile/manage/ManagePublicProfile.svelte";
+import { PROFILE_NAV_ITEMS, PROFILE_PATHS } from "../../../../../components/profile/meSections";
+import { setSession } from "../../../../../auth/session";
+import {
+ apiUsersIdOrHandleGet,
+ apiUsersIdorganizationGet,
+ apiUsersIdpersonGet,
+} from "../../../../../openapi/client";
+import { goto } from "../../../../../utils/navigation";
+
+const { lang, t, session } = Astro.locals;
+
+if (!session) {
+ return goto("/login", { query: { callback: PROFILE_PATHS.profile } });
+}
+
+const activePath = PROFILE_PATHS.profile;
+const navItems = PROFILE_NAV_ITEMS;
+
+const path = { id: String(session.user.id) };
+const headers = session.token.asHttpHeaders;
+
+// The session cookie holds a snapshot of the User taken at login, read the current one
+const [{ data: user }, { data: person }, { data: organization }] = await Promise.all([
+ apiUsersIdOrHandleGet({ path: { idOrHandle: path.id }, headers }),
+ apiUsersIdpersonGet({ path, headers }),
+ // 404 for individual users, resolved as `undefined`
+ session.user.type === "organization"
+ ? apiUsersIdorganizationGet({ path, headers })
+ : Promise.resolve({ data: undefined }),
+]);
+
+// Refresh the cookie too: `updatePublicProfile` builds the new links list from `session.user`
+if (user) {
+ setSession(Astro.cookies, { ...session, user });
+}
+---
+
+
+
+
+
+
+
+
diff --git a/src/pages/[...locale]/project/[idOrSlug]/manage/index.astro b/src/pages/[...locale]/project/[idOrSlug]/manage/index.astro
new file mode 100644
index 00000000..34a0e570
--- /dev/null
+++ b/src/pages/[...locale]/project/[idOrSlug]/manage/index.astro
@@ -0,0 +1,62 @@
+---
+import ProjectManage from "../../../../../components/project/manage/ProjectManage.svelte";
+import App from "../../../../../layouts/App.svelte";
+import Layout from "../../../../../layouts/Layout.astro";
+import { apiProjectsIdOrSlugGet } from "../../../../../openapi/client";
+import { client } from "../../../../../openapi/client/client.gen";
+import { apiUsersIdOrHandleGetUrl } from "../../../../../openapi/client/operation-paths.gen";
+import { goto } from "../../../../../utils/navigation";
+import { NotFound, Unauthorized } from "../../../../../utils/responses";
+
+const { t, lang, session } = Astro.locals;
+
+const { idOrSlug } = Astro.params;
+if (!idOrSlug) {
+ return Astro.rewrite("/500");
+}
+
+const requestUrl = new URL(Astro.request.url);
+const toLogin = () =>
+ goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } });
+
+if (!session) {
+ return toLogin();
+}
+
+const { data: project, error } = await apiProjectsIdOrSlugGet({
+ headers: { ...session.token.asHttpHeaders, "Accept-Language": lang },
+ path: { idOrSlug },
+});
+
+if (error) {
+ if (error.status === NotFound.status) {
+ return Astro.rewrite("/404");
+ }
+
+ // Stale/revoked token: the API rejects it with 401 "Bad credentials."
+ if (error.status === Unauthorized.status) {
+ return toLogin();
+ }
+
+ console.error({ project, error });
+ return Astro.rewrite("/500");
+}
+
+const isAdmin = session.user.roles?.includes("ROLE_ADMIN");
+// Relative IRI, as the API returns it in `project.owner`
+const ownerIri = client.buildUrl({
+ baseUrl: "",
+ url: apiUsersIdOrHandleGetUrl,
+ path: { idOrHandle: session.user.id },
+});
+const isOwner = project.owner === ownerIri;
+if (!isAdmin && !isOwner) {
+ return Astro.rewrite("/403");
+}
+---
+
+
+
+
+
+
diff --git a/src/pages/[...locale]/reviews/[id]/[areaId]/index.astro b/src/pages/[...locale]/reviews/[id]/[areaId]/index.astro
new file mode 100644
index 00000000..e102ca5d
--- /dev/null
+++ b/src/pages/[...locale]/reviews/[id]/[areaId]/index.astro
@@ -0,0 +1,68 @@
+---
+import Layout from "../../../../../layouts/Layout.astro";
+import App from "../../../../../layouts/App.svelte";
+import ReviewChat from "../../../../../components/review/ReviewChat.svelte";
+import {
+ canAccessReview,
+ isConsultant,
+ sessionUserIri,
+} from "../../../../../services/reviewAccess";
+import { getReview, getReviewArea, getReviewAuthors } from "../../../../../services/projectReview";
+import { getReviewedProject } from "../../../../../services/reviewProject";
+import { goto } from "../../../../../utils/navigation";
+
+const { t, lang, session } = Astro.locals;
+
+const { id, areaId } = Astro.params;
+
+if (!session) {
+ const requestUrl = new URL(Astro.request.url);
+ return goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } });
+}
+
+if (!id || !/^\d+$/.test(id) || !areaId || !/^\d+$/.test(areaId)) {
+ return Astro.rewrite("/404");
+}
+
+const { data: review } = getReview(id);
+
+if (!review) {
+ return Astro.rewrite("/404");
+}
+
+// Only the consultant admin and the promoter owning the reviewed project get in.
+if (!(await canAccessReview(session, review))) {
+ return Astro.rewrite("/403");
+}
+
+const { data: area } = getReviewArea(id, areaId);
+
+if (!area) {
+ return Astro.rewrite("/404");
+}
+
+const { data: authors } = getReviewAuthors(id);
+
+const project = await getReviewedProject(review, session, lang);
+
+// Only the consultant admin may assess risks from here, same as on the areas screen.
+const isReviewer = isConsultant(session);
+---
+
+
+
+
+
+
diff --git a/src/pages/[...locale]/reviews/[id]/index.astro b/src/pages/[...locale]/reviews/[id]/index.astro
new file mode 100644
index 00000000..061c88b1
--- /dev/null
+++ b/src/pages/[...locale]/reviews/[id]/index.astro
@@ -0,0 +1,79 @@
+---
+import Layout from "../../../../layouts/Layout.astro";
+import App from "../../../../layouts/App.svelte";
+import ReviewAreas from "../../../../components/review/ReviewAreas.svelte";
+import { canAccessReview, isConsultant } from "../../../../services/reviewAccess";
+import { getReview } from "../../../../services/projectReview";
+import { getReviewedProject } from "../../../../services/reviewProject";
+import { goto } from "../../../../utils/navigation";
+
+const { t, lang, session } = Astro.locals;
+
+const { id } = Astro.params;
+
+if (!session) {
+ const requestUrl = new URL(Astro.request.url);
+ return goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } });
+}
+
+if (!id || !/^\d+$/.test(id)) {
+ return Astro.rewrite("/404");
+}
+
+const { data: review } = getReview(id);
+
+if (!review) {
+ return Astro.rewrite("/404");
+}
+
+// Only the consultant admin and the promoter owning the reviewed project get in.
+if (!(await canAccessReview(session, review))) {
+ return Astro.rewrite("/403");
+}
+
+const project = await getReviewedProject(review, session, lang);
+
+const isReviewer = isConsultant(session);
+
+// Each side reads the other one first: the consultant is named for the promoter and
+// the promoter for the consultant. Without one of them the copy falls back to a
+// version with no hole in it.
+const counterpart = isReviewer ? project?.promoter : project?.reviewer;
+
+const title = counterpart?.handle
+ ? t(
+ isReviewer ? "pages.review.titleReviewer" : "pages.review.titlePromoter",
+ isReviewer
+ ? { promoter: counterpart.handle, project: project?.title ?? "" }
+ : { reviewer: counterpart.handle, project: project?.title ?? "" },
+ )
+ : t("pages.review.titleFallback", { project: project?.title ?? "" });
+
+const subtitle = counterpart?.name
+ ? t(
+ isReviewer ? "pages.review.subtitleReviewer" : "pages.review.subtitlePromoter",
+ isReviewer ? { promoter: counterpart.name } : { reviewer: counterpart.name },
+ )
+ : t("pages.review.subtitleFallback");
+---
+
+
+
+
+
+
+
+
diff --git a/src/pages/[...locale]/search.astro b/src/pages/[...locale]/search.astro
index f4290010..14bebf44 100644
--- a/src/pages/[...locale]/search.astro
+++ b/src/pages/[...locale]/search.astro
@@ -1,22 +1,12 @@
---
import Layout from "../../layouts/Layout.astro";
import App from "../../layouts/App.svelte";
-import SearchFilters from "../../components/search/SearchFilters.svelte";
-import SearchResultsClient from "../../components/search/SearchResultsClient.svelte";
-import { parseSearchParamsFilters } from "../../stores/searchStore";
-
-import type { Project } from "../../openapi/client/types.gen";
+import Search from "../../components/search/Search.svelte";
+import { parseSearchParamsFilters } from "../../utils/searchParams";
const { t, lang } = Astro.locals;
-// Initial projects will be fetched client-side to avoid blocking SSR
-const initialProjects: Project[] = [];
-
-const url = new URL(Astro.request.url);
-const initialFilters = parseSearchParamsFilters(url.searchParams);
-
-// Check if URL has search parameters
-const hasInitialSearch = Object.entries(initialFilters).length > 0;
+const initialFilters = parseSearchParamsFilters(new URL(Astro.request.url).searchParams);
---
@@ -32,20 +22,7 @@ const hasInitialSearch = Object.entries(initialFilters).length > 0;
-
-
-
-
-
-
-
-
-
+
diff --git a/src/pages/[...locale]/static/[fileName].astro b/src/pages/[...locale]/static/[fileName].astro
index ba40761b..d0007676 100644
--- a/src/pages/[...locale]/static/[fileName].astro
+++ b/src/pages/[...locale]/static/[fileName].astro
@@ -20,7 +20,7 @@ const file = await markdownFiles[fileKey]();
const Content = file.default;
---
-
+
diff --git a/src/pages/[...locale]/user/[idOrHandle]/index.astro b/src/pages/[...locale]/user/[idOrHandle]/index.astro
index cd14d3da..e75b1ed6 100644
--- a/src/pages/[...locale]/user/[idOrHandle]/index.astro
+++ b/src/pages/[...locale]/user/[idOrHandle]/index.astro
@@ -8,10 +8,20 @@ import {
apiProjectSupportsmoneyTotalGetCollection,
apiProjectsGetCollection,
} from "../../../../openapi/client";
-import type { Money, ProjectSupport } from "../../../../openapi/client";
-import { apiProjectSupportsGetCollectionUrl } from "../../../../openapi/client/operation-paths.gen";
+import type {
+ ApiProjectsGetCollectionData,
+ Money,
+ Project,
+ ProjectSupport,
+} from "../../../../openapi/client";
+import {
+ apiProjectSupportsGetCollectionUrl,
+ apiUsersIdOrHandleGetUrl,
+} from "../../../../openapi/client/operation-paths.gen";
import type { Campaign } from "../../../../types/campaign";
import { client } from "../../../../openapi/client/client.gen";
+import { toCollectionItems } from "../../../../utils/hydra";
+import { ALL_PUBLIC_STATUSES, expandPublicStatuses } from "../../../../utils/projectStatus";
const { idOrHandle } = Astro.params;
const { lang } = Astro.locals;
@@ -74,39 +84,46 @@ const slugs = userSupports
.map((s) => s.project?.split("/").at(-1))
.filter(Boolean) as string[];
-let campaigns: Campaign[] = [];
-
-if (slugs.length > 0) {
+async function fetchCampaigns(query: ApiProjectsGetCollectionData["query"]): Promise {
try {
- const { data: projects } = await apiProjectsGetCollection({
- query: { "slug[]": slugs },
+ const { data } = await apiProjectsGetCollection({
+ query,
headers: { "Accept-Language": lang },
});
- if (projects) {
- campaigns = projects
- .map((project) => ({
- ...project,
- slug: project.slug!,
- title: project.title!,
- image: project.video?.thumbnail ?? "",
- minimum: project.budget?.minimum?.money as Money,
- optimum: project.budget?.optimum?.money as Money | undefined,
- size: "small",
- }))
- .filter((c) => c.slug && c.title) as Campaign[];
- }
+ return toCollectionItems(data)
+ .map((project) => ({
+ ...project,
+ slug: project.slug!,
+ title: project.title!,
+ image: project.cover || project.video?.thumbnail || "",
+ minimum: project.budget?.minimum?.money as Money,
+ optimum: project.budget?.optimum?.money as Money | undefined,
+ size: "small",
+ }))
+ .filter((c) => c.slug && c.title) as Campaign[];
} catch (e) {
console.error("Error fetching projects:", e);
+ return [];
}
}
+
+const [donatedCampaigns, ownedCampaigns] = await Promise.all([
+ slugs.length ? fetchCampaigns({ "slug[]": slugs }) : [],
+ fetchCampaigns({
+ owner: apiUsersIdOrHandleGetUrl.replace("{idOrHandle}", String(user.id)),
+ "status[]": expandPublicStatuses(ALL_PUBLIC_STATUSES),
+ itemsPerPage: 10,
+ }),
+]);
---
{
+ const { session } = locals;
+ if (!session) return Unauthorized;
+
+ const { checkoutId, items } = (await request.json()) as {
+ checkoutId?: string;
+ items?: CheckoutItem[];
+ };
+
+ if (!checkoutId || !Array.isArray(items)) {
+ return json({ error: 'Missing "checkoutId" or "items" in request body' }, 400);
+ }
+
+ const headers = session.token.asHttpHeaders;
+
+ const { data: checkout, error: checkoutError } = await apiGatewayCheckoutsIdGet({
+ path: { id: checkoutId },
+ headers,
+ });
+
+ if (checkoutError || !checkout) {
+ console.error("[RewardClaims] failed to fetch checkout", checkoutError);
+ return json({ error: "Could not fetch checkout" }, 500);
+ }
+
+ const charges = checkout.charges ?? [];
+ if (charges.length === 0) {
+ return json({ error: "Checkout has no charges" }, 422);
+ }
+
+ const { claims, unmatched } = pairRewardsToCharges(items, charges);
+
+ if (unmatched.length > 0) {
+ // After field matching, the only way to have pending cart items without a
+ // matching API charge is a failure in the matching itself. Log the input
+ // data so it can be refined rather than guessing an association.
+ console.error("[RewardClaims] no matching charge for cart items:", {
+ unmatched,
+ availableCharges: charges,
+ });
+ }
+
+ const result = { created: 0, alreadyExists: 0, failed: 0, unmatched: unmatched.length };
+
+ for (const claim of claims) {
+ try {
+ const { error } = await apiProjectRewardClaimsPost({ headers, body: claim });
+
+ if (!error) {
+ result.created++;
+ continue;
+ }
+ } catch (err) {
+ console.error("[RewardClaims] claim POST failed:", claim, err);
+ }
+
+ // A claim that already exists for the same charge+reward pair means the
+ // previous run got there first. That is the outcome we want, so it counts
+ // as a success — it is what makes re-trials of partially claimed carts safe.
+ if (await claimExists(headers, claim)) {
+ result.alreadyExists++;
+ } else {
+ result.failed++;
+ console.error("[RewardClaims] claim not created:", claim);
+ }
+ }
+
+ return json(result);
+};
+
+/**
+ * Pairs each cart item with the GatewayCharge that paid for it.
+ *
+ * The v4 payment API has no way to know that a charge pays for a Reward, so
+ * the association must be recovered from the local cart. The charges come from
+ * the checkout fetched and validated in `verify.astro`, so they carry the same
+ * fields the cart item was created with.
+ */
+function pairRewardsToCharges(
+ items: CheckoutItem[],
+ charges: GatewayCharge[],
+): { claims: Array<{ reward: string; charge: string }>; unmatched: CheckoutItem[] } {
+ const usedCharges = new Set();
+ const claims: Array<{ reward: string; charge: string }> = [];
+ const unmatched: CheckoutItem[] = [];
+
+ for (const item of items) {
+ if (item.kind !== "reward" || item.reward?.id == null) continue;
+
+ const match = charges.find(
+ (charge) =>
+ !usedCharges.has(charge) &&
+ charge.target === item.target &&
+ charge.title === item.title &&
+ charge.type === item.type &&
+ charge.money.amount === item.money.amount &&
+ charge.money.currency === item.money.currency,
+ );
+
+ if (!match) {
+ unmatched.push(item);
+ continue;
+ }
+
+ usedCharges.add(match);
+ claims.push({
+ reward: client.buildUrl({
+ url: apiProjectRewardsIdGetUrl,
+ path: { id: item.reward.id },
+ }),
+ charge: client.buildUrl({
+ url: apiGatewayChargesIdGetUrl,
+ path: { id: match.id },
+ }),
+ });
+ }
+
+ return { claims, unmatched };
+}
+
+async function claimExists(
+ headers: Record | HeadersInit | undefined,
+ claim: { reward: string; charge: string },
+): Promise {
+ const { data } = await apiProjectRewardClaimsGetCollection({
+ headers,
+ query: { charge: claim.charge, reward: claim.reward, itemsPerPage: 1 },
+ });
+
+ return (data?.length ?? 0) > 0;
+}
+
+function json(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
diff --git a/src/repositories/highlights.ts b/src/repositories/highlights.ts
index 2f40cbfd..9d0931be 100644
--- a/src/repositories/highlights.ts
+++ b/src/repositories/highlights.ts
@@ -65,7 +65,7 @@ class HighlightRepository {
id,
highlight_id AS highlightId,
position,
- project_id AS projectId
+ CAST(project_id AS INTEGER) AS projectId
FROM highlight_slots
WHERE highlight_id = ?
ORDER BY position ASC`,
diff --git a/src/repositories/homeHero.ts b/src/repositories/homeHero.ts
index f5377c94..48133675 100644
--- a/src/repositories/homeHero.ts
+++ b/src/repositories/homeHero.ts
@@ -1,7 +1,39 @@
import { env } from "cloudflare:workers";
+import {
+ HOME_HERO_COLUMN_TO_PROPERTY,
+ HOME_HERO_TABLE,
+ HOME_HERO_TRANSLATABLE_COLUMNS,
+ translationRepository,
+} from "./translations";
+
+/**
+ * The stored translations of one hero row, keyed by locale. Only the columns a
+ * locale actually translates are present. Populated by `getAll()` and used to
+ * re-open the editor and to preview a language without merging into the row.
+ */
+export type HeroTranslations = Record<
+ string,
+ Partial<
+ Pick<
+ HomeHeroRecord,
+ | "title"
+ | "content"
+ | "primaryCtaText"
+ | "primaryCtaLink"
+ | "secondaryCtaText"
+ | "secondaryCtaLink"
+ >
+ >
+>;
+
export interface HomeHeroRecord {
id: number;
+ /**
+ * The locale the base row is written in. Translation rows in the
+ * `translations` table cover the remaining locales.
+ */
+ language: string;
title: string;
content: string;
primaryCtaText: string | null;
@@ -16,9 +48,20 @@ export interface HomeHeroRecord {
mediaType: string | null;
startsAt: Date;
dateCreated: Date;
+ /**
+ * Locale codes the hero content is available in: the base (default) locale
+ * plus every locale with a stored translation row. Populated by `getAll()`.
+ */
+ languages?: string[];
+ /**
+ * Stored translations per locale. Populated by `getAll()` — the editor
+ * seeds its per-language fields from here when a stored block is edited.
+ */
+ translations?: HeroTranslations;
}
const COLUMNS = `id,
+ language,
title,
content,
primary_cta_text AS primaryCtaText,
@@ -51,7 +94,9 @@ class HomeHeroRepository {
}
/**
- * Every hero ever scheduled, most recently authored first.
+ * Every hero ever scheduled, most recently authored first. Each row carries
+ * the `languages` it is available in: the base `language` plus every locale
+ * with a stored translation.
* @returns All stored heroes
*/
public async getAll(): Promise {
@@ -63,14 +108,63 @@ class HomeHeroRepository {
)
.all();
- return results.map(fromRow);
+ const heroes = results.map(fromRow);
+
+ const { results: translationRows } = await this.db
+ .prepare(
+ `SELECT row_id AS rowId, locale, column_name AS columnName, value
+ FROM translations
+ WHERE table_name = ?`,
+ )
+ .bind(HOME_HERO_TABLE)
+ .all<{ rowId: number; locale: string; columnName: string; value: string }>();
+
+ const translatedByRow: Record = {};
+
+ for (const translation of translationRows) {
+ const property =
+ HOME_HERO_COLUMN_TO_PROPERTY[
+ translation.columnName as (typeof HOME_HERO_TRANSLATABLE_COLUMNS)[number]
+ ];
+
+ if (!property) {
+ continue;
+ }
+
+ translatedByRow[translation.rowId] ??= {};
+ translatedByRow[translation.rowId][translation.locale] ??= {};
+ translatedByRow[translation.rowId][translation.locale][
+ property as keyof NonNullable
+ ] = translation.value;
+ }
+
+ const defaultLocale = import.meta.env.PUBLIC_DEFAULT_LANGUAGE;
+
+ for (const hero of heroes) {
+ const baseLocale = hero.language || defaultLocale;
+ const translations = translatedByRow[hero.id] ?? {};
+ const translationLocales = Object.keys(translations);
+
+ hero.languages = [
+ baseLocale,
+ ...new Set(translationLocales.filter((locale) => locale !== baseLocale)),
+ ];
+
+ if (translationLocales.length > 0) {
+ hero.translations = translations;
+ }
+ }
+
+ return heroes;
}
/**
* The hero whose start date has already passed, most recently authored first.
+ * @param locale When given and different from the default locale, the returned
+ * hero has its stored translations for that locale merged on top of the base row.
* @returns The active hero, or null when nothing is scheduled yet
*/
- public async getActive(): Promise {
+ public async getActive(locale?: string): Promise {
const now = Date.now();
const row = await this.db
@@ -84,17 +178,49 @@ class HomeHeroRepository {
.bind(now)
.first();
- return row ? fromRow(row) : null;
+ if (!row) {
+ return null;
+ }
+
+ const hero = fromRow(row);
+
+ const baseLocale = hero.language || import.meta.env.PUBLIC_DEFAULT_LANGUAGE;
+
+ if (!locale || locale === baseLocale) {
+ return hero;
+ }
+
+ const overrides = await translationRepository.get(
+ HOME_HERO_TABLE,
+ hero.id,
+ locale,
+ HOME_HERO_TRANSLATABLE_COLUMNS,
+ );
+
+ for (const [column, value] of Object.entries(overrides)) {
+ if (!value) continue;
+
+ const property =
+ HOME_HERO_COLUMN_TO_PROPERTY[
+ column as (typeof HOME_HERO_TRANSLATABLE_COLUMNS)[number]
+ ];
+
+ hero[property as keyof HomeHeroRecord] = value as never;
+ }
+
+ return hero;
}
/**
* Store a new hero block.
* @param hero The hero to store
+ * @returns The id of the newly created hero
*/
- public async create(hero: Omit): Promise {
+ public async create(hero: Omit): Promise {
const result = await this.db
.prepare(
`INSERT INTO home_hero (
+ language,
title,
content,
primary_cta_text,
@@ -105,9 +231,10 @@ class HomeHeroRepository {
media_type,
starts_at,
date_created
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
+ hero.language,
hero.title,
hero.content,
hero.primaryCtaText,
@@ -124,6 +251,60 @@ class HomeHeroRepository {
if (result.error) {
throw new Error(result.error);
}
+
+ return Number(result.meta.last_row_id);
+ }
+
+ /**
+ * Overwrite an existing hero block.
+ * @param id The hero to overwrite
+ * @param hero The new values
+ */
+ public async update(
+ id: number,
+ hero: Pick<
+ HomeHeroRecord,
+ | "language"
+ | "title"
+ | "content"
+ | "primaryCtaText"
+ | "primaryCtaLink"
+ | "secondaryCtaText"
+ | "secondaryCtaLink"
+ | "mediaUrl"
+ | "mediaType"
+ | "startsAt"
+ >,
+ ): Promise {
+ await this.db
+ .prepare(
+ `UPDATE home_hero SET
+ language = ?,
+ title = ?,
+ content = ?,
+ primary_cta_text = ?,
+ primary_cta_link = ?,
+ secondary_cta_text = ?,
+ secondary_cta_link = ?,
+ media_url = ?,
+ media_type = ?,
+ starts_at = ?
+ WHERE id = ?`,
+ )
+ .bind(
+ hero.language,
+ hero.title,
+ hero.content,
+ hero.primaryCtaText,
+ hero.primaryCtaLink,
+ hero.secondaryCtaText,
+ hero.secondaryCtaLink,
+ hero.mediaUrl,
+ hero.mediaType,
+ hero.startsAt.getTime(),
+ id,
+ )
+ .run();
}
/**
diff --git a/src/repositories/translations.ts b/src/repositories/translations.ts
new file mode 100644
index 00000000..d779999d
--- /dev/null
+++ b/src/repositories/translations.ts
@@ -0,0 +1,102 @@
+import { env } from "cloudflare:workers";
+
+/** The home_hero columns that may be translated. `column_name` = DB column. */
+export const HOME_HERO_TABLE = "home_hero";
+
+export const HOME_HERO_TRANSLATABLE_COLUMNS = [
+ "title",
+ "content",
+ "primary_cta_text",
+ "primary_cta_link",
+ "secondary_cta_text",
+ "secondary_cta_link",
+] as const;
+
+/** Maps a DB column back to the camelCase property of `HomeHeroRecord`. */
+export const HOME_HERO_COLUMN_TO_PROPERTY: Record<
+ (typeof HOME_HERO_TRANSLATABLE_COLUMNS)[number],
+ string
+> = {
+ title: "title",
+ content: "content",
+ primary_cta_text: "primaryCtaText",
+ primary_cta_link: "primaryCtaLink",
+ secondary_cta_text: "secondaryCtaText",
+ secondary_cta_link: "secondaryCtaLink",
+};
+
+class TranslationRepository {
+ db: D1Database;
+
+ constructor(db: D1Database) {
+ this.db = db;
+ }
+
+ /**
+ * The stored translations of one row for one locale.
+ * @returns A map of DB column name to translated value
+ */
+ public async get(
+ tableName: string,
+ rowId: number,
+ locale: string,
+ columns: readonly string[],
+ ): Promise> {
+ if (columns.length === 0) {
+ return {};
+ }
+
+ const placeholders = columns.map(() => "?").join(", ");
+
+ const { results } = await this.db
+ .prepare(
+ `SELECT column_name, value
+ FROM translations
+ WHERE table_name = ? AND row_id = ? AND locale = ?
+ AND column_name IN (${placeholders})`,
+ )
+ .bind(tableName, rowId, locale, ...columns)
+ .all<{ column_name: string; value: string }>();
+
+ return Object.fromEntries(results.map((r) => [r.column_name, r.value]));
+ }
+
+ /**
+ * Upsert translation values for a single row and locale.
+ * @param values A map of DB column name to translated value
+ */
+ public async set(
+ tableName: string,
+ rowId: number,
+ locale: string,
+ values: Record,
+ ): Promise {
+ const stmt = this.db.prepare(
+ `INSERT INTO translations (table_name, row_id, column_name, locale, value)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (table_name, row_id, column_name, locale)
+ DO UPDATE SET value = excluded.value`,
+ );
+
+ await this.db.batch(
+ Object.entries(values).map(([columnName, value]) =>
+ stmt.bind(tableName, rowId, columnName, locale, value),
+ ),
+ );
+ }
+
+ /**
+ * Remove every stored translation of one row. Used when overwriting a block
+ * so locales dropped by the editor do not keep stale translations.
+ * @param tableName The translated table
+ * @param rowId The row whose translations are replaced
+ */
+ public async deleteByRow(tableName: string, rowId: number): Promise {
+ await this.db
+ .prepare(`DELETE FROM translations WHERE table_name = ? AND row_id = ?`)
+ .bind(tableName, rowId)
+ .run();
+ }
+}
+
+export const translationRepository = new TranslationRepository(env.DB);
diff --git a/src/services/projectReview.ts b/src/services/projectReview.ts
new file mode 100644
index 00000000..f5a14f60
--- /dev/null
+++ b/src/services/projectReview.ts
@@ -0,0 +1,295 @@
+import seed from "../mocks/projectReviews.json";
+
+import type {
+ ProjectReview,
+ ProjectReviewRisk,
+ ProjectReviewStatus,
+ ReviewArea,
+ ReviewAuthor,
+ ReviewComment,
+ ReviewSystemEvent,
+} from "../types/projectReview";
+
+/**
+ * Minimal shape of an API failure, matching the `error` the generated client
+ * resolves with so that callers handle both the same way.
+ */
+export interface ReviewError {
+ status: number;
+}
+
+export interface ReviewResult {
+ data?: T;
+ error?: ReviewError;
+}
+
+const NOT_FOUND: ReviewError = { status: 404 };
+
+/** Identifier of the review the placeholders describe, for links built outside the feature. */
+export const PLACEHOLDER_REVIEW_ID = 1;
+
+/**
+ * The reviewed project, as far as it is known before the API carries it.
+ *
+ * Only the placeholders fill this in; a real review resolves its project through
+ * `getReviewedProject` instead.
+ */
+export interface ReviewProject {
+ title: string;
+ status: ProjectReviewStatus;
+ /** IRI of the User promoting the project. */
+ owner: string;
+}
+
+/**
+ * TEMPORARY stand-in for the reviews API.
+ *
+ * The v4 API is still building the review feature, so this module serves the
+ * placeholders in `src/mocks/projectReviews.json` behind the same `{ data, error }`
+ * envelope the generated client returns. Every function here maps one-to-one onto
+ * the endpoint it will eventually become, so landing the API means replacing the
+ * bodies below with `apiReviews*` calls and deleting the mocks — nothing that
+ * consumes this module has to change.
+ *
+ * Writes mutate an in-memory copy seeded from the JSON, so they last for the
+ * lifetime of the isolate and are lost on a cold start. That is enough to exercise
+ * the screens while the API is missing, and it keeps the storage concern in a
+ * single file.
+ *
+ * The placeholders name `/v4/projects/1`, so point `project` at a project that
+ * exists in the environment being used, and the author IRIs at the users that will
+ * sign in, otherwise the review screens answer 404.
+ */
+
+let reviews: Map | undefined;
+
+const seedReviews = seed.reviews as unknown as ProjectReview[];
+
+const seedProjects = seed.projects as unknown as Record;
+
+/**
+ * The project behind a review, as far as the placeholders know it.
+ *
+ * Used to render the review screens for a project the API has no record of yet.
+ * @param review The review being read
+ * @returns The placeholder project, or undefined when the review has none
+ */
+export function getReviewProjectPlaceholder(review: ProjectReview): ReviewProject | undefined {
+ return seedProjects[review.project];
+}
+
+/** Author profiles keyed by their User IRI, used to resolve chat bubbles. */
+const authors = new Map(
+ Object.entries(seed.authors).map(([author, profile]) => [
+ author,
+ { author, ...profile } as ReviewAuthor,
+ ]),
+);
+
+/**
+ * The promoter of the reviewed project, as far as the placeholders know them.
+ *
+ * @param review The review being read
+ * @returns The placeholder profile, or undefined when the placeholders know no author for the project's owner
+ */
+export function getReviewPromoterPlaceholder(review: ProjectReview): ReviewAuthor | undefined {
+ const project = getReviewProjectPlaceholder(review);
+
+ return project ? authors.get(project.owner) : undefined;
+}
+
+/**
+ * The consultant admin assigned to the review, as far as the placeholders know them.
+ *
+ * @param review The review being read
+ * @returns The placeholder profile, or undefined when the placeholders know no author for the reviewer
+ */
+export function getReviewReviewerPlaceholder(review: ProjectReview): ReviewAuthor | undefined {
+ return authors.get(review.reviewer);
+}
+
+/**
+ * Seeds the in-memory store on first use.
+ *
+ * The parsed JSON is cloned so writes never reach the imported module, which the
+ * runtime shares across requests handled by the same isolate.
+ */
+function store(): Map {
+ if (reviews === undefined) {
+ const seeded = new Map(
+ seedReviews.map((review) => [String(review.id), structuredClone(review)]),
+ );
+
+ reviews = seeded;
+
+ return seeded;
+ }
+
+ return reviews;
+}
+
+function find(reviewId: number | string): ProjectReview | undefined {
+ return store().get(String(reviewId));
+}
+
+function findArea(review: ProjectReview, areaId: number | string): ReviewArea | undefined {
+ return review.areas.find((area) => String(area.id) === String(areaId));
+}
+
+/**
+ * Reads a review with its areas and their conversations.
+ * @param id The review identifier, as in `/reviews/{id}`
+ * @returns The review, or a 404 error when no review carries that id
+ */
+export function getReview(id: number | string): ReviewResult {
+ const review = find(id);
+
+ return review ? { data: review } : { error: NOT_FOUND };
+}
+
+/**
+ * Reads a single reviewable area, that is, one risk and its conversation.
+ * @param reviewId The review identifier, as in `/reviews/{id}`
+ * @param areaId The area identifier, as in `/reviews/{id}/areas/{areaId}`
+ * @returns The area, or a 404 error when the review or the area does not exist
+ */
+export function getReviewArea(
+ reviewId: number | string,
+ areaId: number | string,
+): ReviewResult {
+ const review = find(reviewId);
+ const area = review ? findArea(review, areaId) : undefined;
+
+ return area ? { data: area } : { error: NOT_FOUND };
+}
+
+/**
+ * Resolves the profiles of everyone who has written in a review: the consultant,
+ * the promoter, and whoever else takes part in the conversation.
+ * @param reviewId The review identifier, as in `/reviews/{id}`
+ * @returns Every known author of the review, empty when the review does not exist
+ */
+export function getReviewAuthors(reviewId: number | string): ReviewResult {
+ const review = find(reviewId);
+
+ if (!review) return { error: NOT_FOUND };
+
+ const iris = new Set([review.reviewer]);
+
+ for (const area of review.areas) {
+ for (const comment of area.comments) {
+ iris.add(comment.author);
+ }
+ }
+
+ return {
+ data: [...iris].flatMap((iri) => {
+ const profile = authors.get(iri);
+
+ return profile ? [profile] : [];
+ }),
+ };
+}
+
+/**
+ * Appends an entry to the conversation of an area, keeping the id sequence and
+ * the area IRI in one place whether a person or the platform writes it.
+ * @param area The area whose conversation receives the entry
+ * @param author IRI of the User the entry belongs to
+ * @param body The message body, left empty on entries the platform published
+ * @param system Set on entries published by the platform instead of a person
+ * @returns The stored entry
+ */
+function appendComment(
+ area: ReviewArea,
+ author: string,
+ body: string,
+ system?: ReviewSystemEvent,
+): ReviewComment {
+ const now = new Date().toISOString();
+
+ const comment: ReviewComment = {
+ id: Math.max(0, ...area.comments.map((stored) => stored.id)) + 1,
+ author,
+ body,
+ area: area.review.replace(/\/$/, "") + `/areas/${area.id}`,
+ dateCreated: now,
+ dateUpdated: now,
+ };
+
+ if (system) comment.system = system;
+
+ area.comments.push(comment);
+
+ return comment;
+}
+
+/**
+ * Outcome of assessing a risk: the area as it stands, plus the entry the change
+ * published in its own conversation.
+ */
+export interface RiskChange {
+ area: ReviewArea;
+ /** Absent when there was nothing to announce, such as on a first assessment. */
+ entry?: ReviewComment;
+}
+
+/**
+ * Stores the risk assessed on an area, and announces the change in the area's
+ * own conversation so both sides find it where they already talk about it.
+ *
+ * The announcement only happens on a genuine change between two assessed risks:
+ * a first assessment has no previous value to name, and picking the same risk
+ * again is not news.
+ *
+ * @param reviewId The review identifier, as in `/reviews/{id}/areas/{areaId}`
+ * @param areaId The area identifier
+ * @param risk The assessed risk, or null to clear the assessment
+ * @param author IRI of the User making the change, kept as the entry's author
+ * @returns The updated area plus the entry the change published, or a 404 error
+ * when the review or the area does not exist
+ */
+export function setReviewAreaRisk(
+ reviewId: number | string,
+ areaId: number | string,
+ risk: ProjectReviewRisk | null,
+ author: string,
+): ReviewResult {
+ const review = find(reviewId);
+ const area = review ? findArea(review, areaId) : undefined;
+
+ if (!area) return { error: NOT_FOUND };
+
+ const previous = area.risk;
+
+ area.risk = risk;
+
+ const entry =
+ previous && risk && previous !== risk
+ ? appendComment(area, author, "", { from: previous, to: risk })
+ : undefined;
+
+ return { data: { area, entry } };
+}
+
+/**
+ * Appends a message to the conversation of an area.
+ * @param reviewId The review identifier, as in `/reviews/{id}/areas/{areaId}/comments`
+ * @param areaId The area identifier
+ * @param author IRI of the User writing the message
+ * @param body The message body
+ * @returns The stored message, or a 404 error when the review or the area does not exist
+ */
+export function createReviewAreaComment(
+ reviewId: number | string,
+ areaId: number | string,
+ author: string,
+ body: string,
+): ReviewResult {
+ const review = find(reviewId);
+ const area = review ? findArea(review, areaId) : undefined;
+
+ if (!area) return { error: NOT_FOUND };
+
+ return { data: appendComment(area, author, body) };
+}
diff --git a/src/services/projectsService.ts b/src/services/projectsService.ts
deleted file mode 100644
index d5eaf0bb..00000000
--- a/src/services/projectsService.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { apiProjectsGetCollection } from "../openapi/client/sdk.gen";
-import { constrainToPublicStatuses } from "../utils/projectStatus";
-
-import type { AuthError } from "../openapi/api";
-import type { Project } from "../openapi/client/types.gen";
-import type { SearchFilters } from "../stores/searchStore";
-import { get } from "svelte/store";
-import { locale } from "../i18n/store";
-
-/**
- * Simple service wrapper for project API calls
- * Based on OpenAPI client documentation
- */
-export class ProjectsService {
- /**
- * Search projects with filters and pagination
- * Returns raw Project[] from API
- */
- async searchProjects(
- filters: SearchFilters,
- options?: {
- page?: number;
- limit?: number;
- abortSignal?: AbortSignal;
- },
- ): Promise<{
- projects: Project[];
- totalCount: number;
- hasNextPage: boolean;
- }> {
- try {
- const { status, "status[]": statuses, ...restFilters } = filters ?? {};
- const response = await apiProjectsGetCollection({
- query: {
- ...restFilters,
- "status[]": constrainToPublicStatuses([
- ...(statuses ?? []),
- ...(status ? [status] : []),
- ]),
- page: options?.page || 1,
- itemsPerPage: options?.limit || 20,
- },
- headers: { "Accept-Language": get(locale) },
- ...(options?.abortSignal && { signal: options.abortSignal }),
- });
-
- // According to OpenAPI types, response.data is Array
- const projects = (response.data as Project[]) || [];
- const limit = options?.limit || 20;
-
- // Infer hasNextPage: if we got exactly the requested amount, there might be more
- const hasNextPage = projects.length === limit;
-
- // Without Hydra metadata, we can only use the current page count
- // Multiply by current page to give a rough estimate
- const totalCount = hasNextPage
- ? projects.length * (options?.page || 1) + 1 // At least one more page
- : projects.length * (options?.page || 1);
-
- return {
- projects,
- totalCount,
- 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;
- }
-
- // Wrap other errors with context
- if (error instanceof Error) {
- throw new Error(`Project search failed: ${error.message}`, { cause: error });
- }
-
- throw new Error("Project search failed", { cause: error });
- }
- }
-
- /**
- * Get initial projects for SSR
- * Returns empty array on error
- */
- async getInitialProjects(
- locale: string,
- options?: {
- limit?: number;
- abortSignal?: AbortSignal;
- },
- ): Promise {
- try {
- const response = await apiProjectsGetCollection({
- query: {
- page: 1,
- itemsPerPage: options?.limit || 12,
- },
- headers: {
- "Accept-Language": locale,
- },
- ...(options?.abortSignal && { signal: options.abortSignal }),
- });
-
- return response.data || [];
- } catch {
- return [];
- }
- }
-
- /**
- * Helper: Check if there are more pages
- * API doesn't return pagination metadata, so we infer from results
- */
- hasNextPage(projects: Project[], limit: number): boolean {
- return projects.length === limit;
- }
-}
-
-// Export singleton instance
-export const projectsService = new ProjectsService();
diff --git a/src/services/reviewAccess.ts b/src/services/reviewAccess.ts
new file mode 100644
index 00000000..82d160f6
--- /dev/null
+++ b/src/services/reviewAccess.ts
@@ -0,0 +1,71 @@
+import { getReviewProjectPlaceholder } from "./projectReview";
+import { apiProjectsIdOrSlugGet, apiUsersGetCollectionUrl } from "../openapi/client";
+import { extractId } from "../utils/extractId";
+
+import type { Session } from "../auth/types";
+import type { ProjectReview } from "../types/projectReview";
+
+/**
+ * Whether the signed in user acts as the consultant admin of a review.
+ *
+ * Only this role may assess risks and move the reviewed project through its
+ * review statuses.
+ * @param session The current session
+ * @returns True when the user holds the admin role
+ */
+export function isConsultant(session?: Session): boolean {
+ return !!session?.user.roles?.includes("ROLE_ADMIN");
+}
+
+/**
+ * Whether the signed in user may read a review.
+ *
+ * A review is visible to the consultant admin carrying it out and to the promoter
+ * who owns the reviewed project.
+ * @param session The current session
+ * @param review The review being accessed
+ * @returns True when the user is the consultant or the promoter of the project
+ */
+export async function canAccessReview(
+ session: Session | undefined,
+ review: ProjectReview,
+): Promise {
+ if (!session) return false;
+ if (isConsultant(session)) return true;
+
+ const projectId = extractId(review.project);
+ if (!projectId) return false;
+
+ // A promoter only reaches the reviews of their own projects, and their own User
+ // resource already lists them, so this costs no extra request.
+ if (Array.isArray(session.user.projects)) {
+ return session.user.projects.some((iri) => extractId(iri) === projectId);
+ }
+
+ // Fall back to the Project's owner for sessions whose User carries no project
+ // list, which is what happens when the API stops hydrating it.
+ const { data: project } = await apiProjectsIdOrSlugGet({
+ path: { idOrSlug: projectId },
+ headers: session.token.asHttpHeaders,
+ });
+
+ if (project?.owner) {
+ return extractId(project.owner) === String(session.user.id);
+ }
+
+ // Nothing to read an owner from: fall back to the placeholder of the mocks, which
+ // is what a review of a project the API has no record of yet resolves to.
+ const placeholder = getReviewProjectPlaceholder(review);
+
+ return !!placeholder && extractId(placeholder.owner) === String(session.user.id);
+}
+
+/**
+ * Builds the IRI of the User the current session belongs to, the shape the API
+ * uses to reference a comment author.
+ * @param session The current session
+ * @returns The User IRI, or null when there is no session
+ */
+export function sessionUserIri(session: Session): string {
+ return apiUsersGetCollectionUrl + `/${session.user.id}`;
+}
diff --git a/src/services/reviewProject.ts b/src/services/reviewProject.ts
new file mode 100644
index 00000000..f58e1f3f
--- /dev/null
+++ b/src/services/reviewProject.ts
@@ -0,0 +1,128 @@
+import {
+ getReviewProjectPlaceholder,
+ getReviewPromoterPlaceholder,
+ getReviewReviewerPlaceholder,
+} from "./projectReview";
+import { apiProjectsIdOrSlugGet, apiUsersIdOrHandleGet } from "../openapi/client";
+import { extractId } from "../utils/extractId";
+
+import type { Session } from "../auth/types";
+import type { Locale } from "../i18n/locales";
+import type { Project } from "../openapi/client";
+import type { ProjectReview, ReviewAuthor } from "../types/projectReview";
+
+/**
+ * One of the two people a review is about, as the review headers name them.
+ */
+export interface ReviewPerson {
+ /** `displayName` from the API, falling back to the handle when unset. */
+ name: string;
+ /** Username, shown as `@handle` in titles. */
+ handle: string;
+ avatar?: string;
+}
+
+/**
+ * The project behind a review, plus who is on either side of it.
+ */
+export interface ReviewedProject {
+ title: string;
+ /** The User promoting the project. */
+ promoter?: ReviewPerson;
+ /** The consultant admin assigned to the review. */
+ reviewer?: ReviewPerson;
+ status?: Project["status"];
+}
+
+/**
+ * Resolves the project a review is about, together with the two people naming it.
+ *
+ * The API is the source of truth. The placeholder from the mocks only fills the gap
+ * for a review whose project has no record in the API yet, so the screens stay
+ * reachable while that data is still being built; both go away with the mocks.
+ * @param review The review being read
+ * @param session The current session, needed to read a project only its owner or an admin may see
+ * @param lang Active locale, sent so the API answers with translated content
+ * @returns The project and its people, or undefined when neither the API nor the placeholders know it
+ */
+export async function getReviewedProject(
+ review: ProjectReview,
+ session: Session,
+ lang: Locale,
+): Promise {
+ const projectId = extractId(review.project);
+ const placeholder = getReviewProjectPlaceholder(review);
+ const promoterFallback = getReviewPromoterPlaceholder(review);
+ const reviewerFallback = getReviewReviewerPlaceholder(review);
+
+ const reviewer = await getPerson(review.reviewer, reviewerFallback, session, lang);
+
+ if (!projectId) {
+ return {
+ title: placeholder?.title ?? "",
+ promoter: toPerson(promoterFallback),
+ reviewer,
+ status: placeholder?.status,
+ };
+ }
+
+ const { data, error } = await apiProjectsIdOrSlugGet({
+ path: { idOrSlug: projectId },
+ headers: { ...session.token.asHttpHeaders, "Accept-Language": lang },
+ });
+
+ // A missing project is not fatal for a review: the review is the resource on
+ // screen, and the placeholders may still know which project it belongs to.
+ if (!data && error && error.status !== 404) {
+ console.error({ review: review.id, error });
+ }
+
+ const promoter = data?.owner
+ ? await getPerson(data.owner, promoterFallback, session, lang)
+ : toPerson(promoterFallback);
+
+ if (data) {
+ return { title: data.title ?? "", promoter, reviewer, status: data.status };
+ }
+
+ return {
+ title: placeholder?.title ?? "",
+ promoter,
+ reviewer,
+ status: placeholder?.status,
+ };
+}
+
+/**
+ * Resolves a User IRI into the profile the headers need.
+ *
+ * Falls back to the placeholders so a review backed by mocks still names both
+ * people instead of rendering a title with a hole in it.
+ */
+async function getPerson(
+ iri: string,
+ fallback: ReviewAuthor | undefined,
+ session: Session,
+ lang: Locale,
+): Promise {
+ const id = extractId(iri);
+
+ if (!id) return toPerson(fallback);
+
+ const { data, error } = await apiUsersIdOrHandleGet({
+ path: { idOrHandle: id },
+ headers: { ...session.token.asHttpHeaders, "Accept-Language": lang },
+ });
+
+ if (error && error.status !== 404) {
+ console.error({ user: id, error });
+ }
+
+ if (!data) return toPerson(fallback);
+
+ return { name: data.displayName ?? data.handle, handle: data.handle, avatar: data.avatar };
+}
+
+function toPerson(author?: ReviewAuthor): ReviewPerson | undefined {
+ return author ? { name: author.displayName, handle: author.handle } : undefined;
+}
diff --git a/src/stores/checkoutsStore.ts b/src/stores/checkoutsStore.ts
index 52c71331..a1244aa0 100644
--- a/src/stores/checkoutsStore.ts
+++ b/src/stores/checkoutsStore.ts
@@ -43,10 +43,20 @@ export interface StoredCheckout {
key: string;
items: Record;
updatedAt: number;
+ /**
+ * Marks a checkout that has been fully claimed/deprecated. The record is
+ * kept (soft delete) so the data is never lost, but it is no longer
+ * offered to the shopper or re-submitted by `RewardClaims`.
+ */
+ finalised?: boolean;
+ finalisedAt?: number;
}
type CheckoutState = {
items: Record;
+ /** Set once the whole cart has been claimed; see `StoredCheckout`. */
+ finalised?: boolean;
+ finalisedAt?: number;
};
export interface CheckoutStore {
@@ -126,8 +136,8 @@ function schedulePersist() {
const key = activeKey;
enqueue(async () => {
- const { items } = get(state);
- await checkoutRepo.update(key, { items });
+ const { items, finalised, finalisedAt } = get(state);
+ await checkoutRepo.update(key, { items, finalised, finalisedAt });
});
}
@@ -147,9 +157,17 @@ if (isBrowser) {
let merged: Record = {};
state.update((current) => {
merged = { ...loaded, ...current.items };
- return { items: merged };
+ return {
+ items: merged,
+ finalised: current.finalised ?? record?.finalised,
+ finalisedAt: current.finalisedAt ?? record?.finalisedAt,
+ };
+ });
+ await checkoutRepo.update(key, {
+ items: merged,
+ finalised: record?.finalised,
+ finalisedAt: record?.finalisedAt,
});
- await checkoutRepo.update(key, { items: merged });
}
hydrated.set(true);
@@ -271,16 +289,29 @@ export const cart: CheckoutStore = {
clearForUser: (userId?: string | number) => {
skipHydrationMerge = true;
- state.set({ items: {} });
+
+ // Soft delete: mark the cart as finalised instead of destroying it, so
+ // the record (and the reason it was claimed) is never lost. RewardClaims
+ // exits early on the `finalised` flag, so nothing is re-submitted.
+ const now = Date.now();
+ state.set({ items: {}, finalised: true, finalisedAt: now });
if (!isBrowser) return;
enqueue(async () => {
- await checkoutRepo.remove(GUEST_CHECKOUT_KEY);
+ await checkoutRepo.update(GUEST_CHECKOUT_KEY, {
+ items: {},
+ finalised: true,
+ finalisedAt: now,
+ });
const key = checkoutKeyForUser(userId);
if (key !== GUEST_CHECKOUT_KEY) {
- await checkoutRepo.remove(key);
+ await checkoutRepo.update(key, {
+ items: {},
+ finalised: true,
+ finalisedAt: now,
+ });
}
});
dropLegacyCart();
diff --git a/src/stores/projectCache.ts b/src/stores/projectCache.ts
deleted file mode 100644
index 4ff5c5ce..00000000
--- a/src/stores/projectCache.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { writable } from "svelte/store";
-
-import type { Project } from "../openapi/client/types.gen";
-
-/**
- * Project Cache Store
- *
- * Session-level cache for project details to avoid refetching the same projects
- * across component mounts and page navigations.
- *
- * Key features:
- * - Multi-key indexing: Projects are indexed by ID, slug, and original idOrSlug
- * - Persistent across component mounts
- * - Shared across all components
- * - Automatic cache validation
- *
- * Usage:
- * ```ts
- * import { projectCache } from './stores/projectCache';
- *
- * // Add a project to the cache
- * projectCache.add(project, originalIdOrSlug);
- *
- * // Get a project from the cache
- * const project = projectCache.get(idOrSlug);
- *
- * // Clear the cache
- * projectCache.clear();
- * ```
- */
-
-interface ProjectCacheStore {
- projects: Map;
-}
-
-function createProjectCache() {
- const { subscribe, update } = writable({
- projects: new Map(),
- });
-
- return {
- subscribe,
-
- /**
- * Add a project to the cache using multiple keys for reliable lookups
- * @param project - The project to cache
- * @param originalKey - Optional original ID or slug used to fetch this project
- */
- add: (project: Project, originalKey?: string) => {
- update((state) => {
- const newProjects = new Map(state.projects);
-
- // Always add using the original key if provided
- if (originalKey) {
- newProjects.set(originalKey, project);
- }
-
- // Add by slug (preferred for URLs)
- if (project.slug) {
- newProjects.set(project.slug, project);
- }
-
- // Add by ID (for API IRI lookups)
- if (project.id) {
- newProjects.set(project.id.toString(), project);
- }
-
- return { projects: newProjects };
- });
- },
-
- /**
- * Add multiple projects to the cache
- * @param projects - Array of projects to cache
- */
- addMany: (projects: Project[]) => {
- update((state) => {
- const newProjects = new Map(state.projects);
-
- projects.forEach((project) => {
- // Add by slug
- if (project.slug) {
- newProjects.set(project.slug, project);
- }
-
- // Add by ID
- if (project.id) {
- newProjects.set(project.id.toString(), project);
- }
- });
-
- return { projects: newProjects };
- });
- },
-
- /**
- * Get a project from the cache by ID or slug
- * @param key - Project ID or slug
- * @returns The cached project or undefined
- */
- get: (key: string | undefined): Project | undefined => {
- if (!key) return undefined;
-
- let result: Project | undefined;
- update((state) => {
- result = state.projects.get(key);
- return state;
- });
- return result;
- },
-
- /**
- * Check if a project exists in the cache
- * @param key - Project ID or slug
- * @returns True if the project is cached
- */
- has: (key: string | undefined): boolean => {
- if (!key) return false;
-
- let result = false;
- update((state) => {
- result = state.projects.has(key);
- return state;
- });
- return result;
- },
-
- /**
- * Clear all cached projects
- */
- clear: () => {
- update(() => ({ projects: new Map() }));
- },
-
- /**
- * Get cache statistics for debugging
- * @returns Object containing cache size and sample keys
- */
- getStats: () => {
- const stats = { size: 0, sampleKeys: [] as string[] };
- update((state) => {
- stats.size = state.projects.size;
- stats.sampleKeys = Array.from(state.projects.keys()).slice(0, 10);
- return state;
- });
- return stats;
- },
- };
-}
-
-export const projectCache = createProjectCache();
diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts
deleted file mode 100644
index 92a04afe..00000000
--- a/src/stores/searchStore.ts
+++ /dev/null
@@ -1,509 +0,0 @@
-import { writable, derived, get } from "svelte/store";
-
-import { projectsService } from "../services/projectsService";
-
-import type { ApiProjectsGetCollectionData, Project } from "../openapi/client/types.gen";
-
-export type SearchFilters = ApiProjectsGetCollectionData["query"];
-
-export interface SearchState {
- filters: SearchFilters;
- results: Project[];
- isLoading: boolean;
- hasError: boolean;
- errorMessage: string;
- totalCount: number;
- hasSearched: boolean;
- lastSearchTime: number;
- // Pagination state
- currentPage: number;
- itemsPerPage: number;
- hasNextPage: boolean;
- hasPrevPage: boolean;
- isLoadingMore: boolean;
- currentAbortController?: AbortController;
-}
-
-// Browser check for SSR safety
-const isBrowser = typeof window !== "undefined";
-
-// Initialize search state
-function getInitialState(): SearchState {
- return {
- filters: {
- page: 1,
- itemsPerPage: 18,
- },
- results: [],
- isLoading: false,
- hasError: false,
- errorMessage: "",
- totalCount: 0,
- hasSearched: false,
- lastSearchTime: 0,
- // Pagination state
- currentPage: 1,
- itemsPerPage: 18,
- hasNextPage: false,
- hasPrevPage: false,
- isLoadingMore: false,
- currentAbortController: undefined,
- };
-}
-
-export function parseSearchParamsFilters(searchParams: URLSearchParams) {
- const filters: Record = {};
-
- for (const [key] of searchParams.entries()) {
- const values = searchParams.getAll(key);
-
- if (values.length > 1 || key.endsWith("[]")) {
- filters[key] = values;
- } else {
- filters[key] = values[0];
- }
- }
-
- return filters;
-}
-
-// Load initial state from URL parameters if available
-function loadInitialState(): SearchState {
- if (!isBrowser) return getInitialState();
-
- const state = getInitialState();
- const urlParams = new URLSearchParams(window.location.search);
-
- // Initialize filters from URL parameters using API field names
- if (state.filters) {
- state.filters = parseSearchParamsFilters(urlParams);
-
- if ([...urlParams.keys()].length > 0) {
- state.hasSearched = true;
- }
- }
- return state;
-}
-
-function createSearchStore() {
- const { subscribe, set, update } = writable(loadInitialState());
-
- // Direct search function - executes immediately when called
- const performSearch = async (filters: SearchFilters) => {
- try {
- const currentState = get(store);
-
- // Cancel previous request if exists
- if (currentState.currentAbortController) {
- currentState.currentAbortController.abort();
- }
-
- // Create new abort controller
- const abortController = new AbortController();
-
- update((state) => ({
- ...state,
- isLoading: true,
- isLoadingMore: false,
- hasError: false,
- errorMessage: "",
- currentAbortController: abortController,
- }));
-
- const response = await projectsService.searchProjects(filters, {
- page: currentState.currentPage,
- limit: currentState.itemsPerPage,
- abortSignal: abortController.signal,
- });
-
- // Check if request wasn't cancelled
- if (!abortController.signal.aborted) {
- const hasPrevPage = currentState.currentPage > 1;
-
- update((state) => ({
- ...state,
- results: response.projects,
- totalCount: response.totalCount,
- hasNextPage: response.hasNextPage,
- hasPrevPage,
- isLoading: false,
- hasSearched: true,
- lastSearchTime: Date.now(),
- currentAbortController: undefined,
- }));
- }
- } catch (error) {
- // 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,
- }));
- }
- };
-
- const store = {
- subscribe,
-
- // Update filters without triggering search
- updateFilters: (newFilters: Partial) => {
- update((state) => ({
- ...state,
- filters: { ...state.filters, ...newFilters },
- }));
- },
-
- // Initialize filters (for SSR hydration)
- initializeFilters: (newFilters: Partial) => {
- update((state) => ({
- ...state,
- filters: { ...state.filters, ...newFilters },
- }));
- },
-
- // Set search results
- setResults: (results: Project[], totalCount: number = results.length) =>
- update((state) => ({
- ...state,
- results,
- totalCount,
- isLoading: false,
- hasError: false,
- errorMessage: "",
- hasSearched: true,
- lastSearchTime: Date.now(),
- })),
-
- // Set initial results without marking as searched (for SSR data)
- setInitialResults: (results: Project[], totalCount: number = results.length) => {
- return update((state) => ({
- ...state,
- results,
- totalCount,
- isLoading: false,
- hasError: false,
- errorMessage: "",
- hasSearched: false,
- lastSearchTime: Date.now(),
- }));
- },
-
- // Set loading state
- setLoading: (isLoading: boolean) =>
- update((state) => ({
- ...state,
- isLoading,
- hasError: false,
- errorMessage: "",
- })),
-
- // Set error state
- setError: (errorMessage: string) =>
- update((state) => ({
- ...state,
- isLoading: false,
- hasError: true,
- errorMessage,
- })),
-
- // Clear search results
- clearResults: () =>
- update((state) => ({
- ...state,
- results: [],
- totalCount: 0,
- hasSearched: false,
- hasError: false,
- errorMessage: "",
- })),
-
- // Clear all filters
- clearFilters: () =>
- update((state) => ({
- ...state,
- filters: {
- page: 1,
- itemsPerPage: 18,
- },
- })),
-
- // Reset to initial state
- reset: () => set(getInitialState()),
-
- // Update URL parameters to reflect current filters
- updateUrl: () => {
- if (!isBrowser) return;
-
- const state = get(searchStore);
- const urlParams = new URLSearchParams();
- const filters = state.filters ?? {};
-
- Object.entries(filters).forEach(([key, value]) => {
- if (value === undefined || value === null || value === "") return;
-
- if (Array.isArray(value)) {
- value.forEach((item) => {
- urlParams.append(key, String(item));
- });
- } else {
- if (key !== "page" && key !== "itemsPerPage") {
- urlParams.set(key, String(value));
- }
- }
- });
-
- const newUrl = urlParams.toString()
- ? `${window.location.pathname}?${urlParams.toString()}`
- : window.location.pathname;
-
- window.history.replaceState({}, "", newUrl);
- },
-
- // Initialize store with server-provided data
- initializeFromServer: (
- filters: SearchFilters,
- results: Project[],
- totalCount: number,
- pagination?: {
- currentPage: number;
- hasNext: boolean;
- hasPrev: boolean;
- },
- ) =>
- update((state) => ({
- ...state,
- filters,
- results,
- totalCount,
- currentPage: pagination?.currentPage || 1,
- hasNextPage: pagination?.hasNext || false,
- hasPrevPage: pagination?.hasPrev || false,
- hasSearched:
- totalCount > 0 ||
- !!(filters?.title || (filters?.["categories[]"]?.length ?? 0) > 0),
- isLoading: false,
- hasError: false,
- errorMessage: "",
- lastSearchTime: Date.now(),
- })),
-
- // Search with API (manual trigger)
- searchWithApi: async (resetPage = true) => {
- if (resetPage) {
- update((state) => ({
- ...state,
- currentPage: 1,
- }));
- }
-
- // Get the updated state AFTER resetting the page
- const currentState = get(store);
- await performSearch(currentState.filters);
- },
-
- // Load next page
- loadNextPage: async () => {
- const currentState = get(store);
- if (!currentState.hasNextPage || currentState.isLoading) return;
-
- update((state) => ({
- ...state,
- currentPage: state.currentPage + 1,
- }));
-
- await store.searchWithApi(false);
- },
-
- // Load previous page
- loadPreviousPage: async () => {
- const currentState = get(store);
- if (
- !currentState.hasPrevPage ||
- currentState.isLoading ||
- currentState.currentPage <= 1
- )
- return;
-
- update((state) => ({
- ...state,
- currentPage: state.currentPage - 1,
- }));
-
- await store.searchWithApi(false);
- },
-
- // Go to specific page
- goToPage: async (page: number) => {
- const currentState = get(store);
- if (page < 1 || currentState.isLoading) return;
-
- update((state) => ({
- ...state,
- currentPage: page,
- }));
-
- await store.searchWithApi(false);
- },
-
- // Cancel current API request
- cancelRequest: () => {
- update((state) => {
- if (state.currentAbortController) {
- state.currentAbortController.abort();
- }
- return {
- ...state,
- isLoading: false,
- currentAbortController: undefined,
- };
- });
- },
-
- // Load more results (accumulative pagination)
- loadMoreResults: async () => {
- const currentState = get(store);
- if (!currentState.hasNextPage || currentState.isLoading) return;
-
- try {
- // Increment page before fetching
- update((state) => ({
- ...state,
- currentPage: state.currentPage + 1,
- }));
-
- const nextPageState = get(store);
-
- // Cancel previous request if exists
- if (nextPageState.currentAbortController) {
- nextPageState.currentAbortController.abort();
- }
-
- // Create new abort controller
- const abortController = new AbortController();
-
- update((state) => ({
- ...state,
- isLoading: true,
- isLoadingMore: true,
- hasError: false,
- errorMessage: "",
- currentAbortController: abortController,
- }));
-
- const response = await projectsService.searchProjects(nextPageState.filters, {
- page: nextPageState.currentPage,
- limit: nextPageState.itemsPerPage,
- abortSignal: abortController.signal,
- });
-
- // Check if request wasn't cancelled
- if (!abortController.signal.aborted) {
- const hasPrevPage = nextPageState.currentPage > 1;
-
- update((state) => ({
- ...state,
- // Append new results to existing results
- results: [...state.results, ...response.projects],
- totalCount: response.totalCount,
- hasNextPage: response.hasNextPage,
- hasPrevPage,
- isLoading: false,
- isLoadingMore: false,
- hasSearched: true,
- lastSearchTime: Date.now(),
- currentAbortController: undefined,
- }));
- }
- } catch (error) {
- // Only set error if request wasn't cancelled
- // Keep existing results on error
- if (!(error instanceof Error) || error.name !== "AbortError") {
- update((state) => ({
- ...state,
- isLoading: false,
- isLoadingMore: false,
- hasError: true,
- errorMessage:
- error instanceof Error ? error.message : "Failed to load more results",
- currentAbortController: undefined,
- // Revert page increment on error
- currentPage: state.currentPage - 1,
- }));
- }
- }
- },
- };
-
- return store;
-}
-
-// Create the main search store
-export const searchStore = createSearchStore();
-
-// Derived stores for computed values
-export const searchFilters = derived(searchStore, ($searchStore) => $searchStore.filters || {});
-
-export const searchResults = derived(searchStore, ($searchStore) => $searchStore.results);
-
-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,
-);
-
-export const hasSearchResults = derived(
- searchStore,
- ($searchStore) => $searchStore.results.length > 0,
-);
-
-export const hasActualSearchResults = derived(
- searchStore,
- ($searchStore) => $searchStore.hasSearched && $searchStore.results.length > 0,
-);
-
-export const hasInitialResults = derived(
- searchStore,
- ($searchStore) => !$searchStore.hasSearched && $searchStore.results.length > 0,
-);
-
-export const isEmpty = derived(
- searchStore,
- ($searchStore) => $searchStore.hasSearched && $searchStore.results.length === 0,
-);
-
-export const resultCount = derived(searchStore, ($searchStore) => $searchStore.totalCount);
-
-// Derived store to check if any filters are active
-export const hasActiveFilters = derived(searchStore, ($searchStore) => {
- const paginationKeys = ["page", "itemsPerPage"];
- return Object.entries($searchStore.filters ?? {}).some(
- ([key, value]) =>
- !paginationKeys.includes(key) &&
- (Array.isArray(value) ? value.length > 0 : value != null && value !== ""),
- );
-});
-
-// Pagination derived stores
-export const currentPage = derived(searchStore, ($searchStore) => $searchStore.currentPage);
-
-export const hasNextPage = derived(searchStore, ($searchStore) => $searchStore.hasNextPage);
-
-export const hasPrevPage = derived(searchStore, ($searchStore) => $searchStore.hasPrevPage);
-
-// Pagination info derived store
-export const paginationInfo = derived(searchStore, ($searchStore) => ({
- currentPage: $searchStore.currentPage,
- totalCount: $searchStore.totalCount,
- hasNext: $searchStore.hasNextPage,
- hasPrev: $searchStore.hasPrevPage,
- isLoading: $searchStore.isLoading,
-}));
diff --git a/src/styles/global.css b/src/styles/global.css
index c7e234ec..e42d22cc 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -15,33 +15,34 @@
@theme {
/* Light Theme (default) */
- --color-primary: #59e9d3;
- --color-secondary: #462949;
- --color-tertiary: #e94668;
- --color-content: #575757;
+ --color-primary: #ff4d9a;
+ --color-secondary: #2ee6f6;
+ --color-tertiary: #a66bff;
+ --color-content: #f2f2f7;
/* Extended color tokens for components */
- --color-white: #fbfbfb;
- --color-black: #3d3d3d;
- --color-grey: #f3f3ef;
- --color-purple-soft: #faf9ff;
- --color-purple-med: #f6f5ff;
+ --color-white: #0f0f14;
+ --color-black: #ffffff;
+ --color-grey: #23262e;
+ --color-purple-soft: #171a22;
+ --color-purple-med: #211c36;
/* Scrim tras diálogos y modales: gris cálido al 70% */
--color-overlay: #878282b2;
- --color-variant1: #e6e5f7;
- --color-variant2: #99ffcc;
- --color-variant3: #72d8ff;
- --color-variant4: #ffff99;
+ --color-variant1: #4a1e2b;
+ --color-variant2: #24d3a1;
+ --color-variant3: #2ee6f6;
+ --color-variant4: #ffc857;
- --color-semantic-error: #e9b1bd;
- --color-semantic-success: #b1e9e1;
- --color-semantic-notification: #c2eeff;
- --color-semantic-warning: #ffffc2;
+ --color-semantic-error: #ff6b6b;
+ --color-semantic-success: #24d3a1;
+ --color-semantic-notification: #183b46;
+ --color-semantic-warning: #ffc857;
/* Declared by the `` component in Layout.astro; already carries its own fallbacks. */
--font-body: var(--font-karla);
+ --font-title: var(--font-boldonse);
--text-body-small: 0.875rem;
--text-double: 2rem;
}
@@ -89,6 +90,12 @@ body.grid {
width: 100%;
}
+h1,
+h2,
+h3 {
+ font-family: var(--font-title);
+}
+
astro-island {
display: contents;
}
diff --git a/src/styles/marked.css b/src/styles/marked.css
index 00faf1d0..ed7ecca4 100644
--- a/src/styles/marked.css
+++ b/src/styles/marked.css
@@ -37,7 +37,15 @@
list-style: initial;
}
.marked-content ol {
- list-style: initial;
+ padding-left: var(--text-xl);
+ padding-bottom: var(--text-lg);
+ list-style: decimal;
+}
+
+.marked-content blockquote {
+ border-left: 4px solid var(--color-primary);
+ padding-left: var(--text-lg);
+ margin-bottom: var(--text-lg);
}
.marked-content h3 {
diff --git a/src/types/campaign.ts b/src/types/campaign.ts
index 599522b2..c48023e1 100644
--- a/src/types/campaign.ts
+++ b/src/types/campaign.ts
@@ -18,7 +18,6 @@ export type CampaignSize = "small" | "large";
* - `obtained` and `minimum`: Funding amounts from project.funding
* - `image`: Path from project.image
* - `userDonations`: Aggregated from user's contributions
- * - `daysRemaining`: Calculated from campaign end date
* - `hasMatchfunding`: Indicates if project participates in matchfunding
* - `tags`: Custom display tags for the project
* - `category`: Project category for filtering and display
@@ -42,8 +41,6 @@ export interface Campaign extends Project {
userDonations?: Money;
/** Project category: solidary, ecology, democracy, culture, etc. */
category?: string;
- /** Days remaining until campaign ends */
- daysRemaining?: number;
/** Whether this project participates in matchfunding */
hasMatchfunding?: boolean;
/** Custom display tags */
diff --git a/src/types/footer.ts b/src/types/footer.ts
index 0c8cae81..7580896a 100644
--- a/src/types/footer.ts
+++ b/src/types/footer.ts
@@ -8,6 +8,10 @@ export interface FooterLinkItem {
href: string;
}
+export interface FooterLinkSocial extends FooterLinkItem {
+ icon: Component<{ width?: string | number; height?: string | number }>;
+}
+
export interface FooterNavColumn {
/** i18n key for the column heading */
titleKey: string;
@@ -23,13 +27,6 @@ export interface FooterPartner {
class?: ClassNameValue;
}
-export interface FooterSocialLink {
- icon: Component<{ width?: string | number; height?: string | number }>;
- href: string;
- /** i18n key for the link's aria-label */
- ariaLabelKey: string;
-}
-
export interface FooterConfig {
/** first band — non-linked funding logos */
funding: FooterPartner[];
@@ -37,7 +34,7 @@ export interface FooterConfig {
partOf: FooterPartner[];
navColumns: FooterNavColumn[];
legalLinks: FooterLinkItem[];
- social: FooterSocialLink[];
+ social: FooterLinkSocial[];
brand: {
backgroundImage: string;
logo: { src: string; alt: string };
diff --git a/src/types/me-page.ts b/src/types/me-page.ts
index e50fd6c0..d0810db5 100644
--- a/src/types/me-page.ts
+++ b/src/types/me-page.ts
@@ -71,10 +71,6 @@ export interface DonationItem {
* Project slug for URL construction
*/
projectSlug: string;
- /**
- * Date of the donation (ISO 8601 format)
- */
- date: string;
}
/**
diff --git a/src/types/projectReview.ts b/src/types/projectReview.ts
index a167f3bc..06eef56b 100644
--- a/src/types/projectReview.ts
+++ b/src/types/projectReview.ts
@@ -1,26 +1,110 @@
/**
- * Risk level assigned to a reviewed area of a project.
+ * Risk level assessed on a review area.
*
* Values match the `domain.review.risks.*` translation keys.
*/
export type ProjectReviewRisk = "low" | "medium" | "high";
+/** Stage of the project life-cycle a review belongs to. */
+export type ProjectReviewType = "campaign" | "financial";
+
/**
- * A single reviewable area of a project during campaign or funding review.
+ * Project statuses a consultant admin can move a project to from a review.
+ *
+ * `to_campaign_review` is deliberately left out: a review can only exist once the
+ * project has left that state, so offering it would allow a project to be pushed
+ * back to a step it already completed.
+ */
+export const PROJECT_REVIEW_STATUSES = [
+ "in_campaign_review",
+ "in_campaign_review.to_change",
+ "in_campaign_review.to_review",
+ "campaign_review.rejected",
+] as const;
+
+export type ProjectReviewStatus = (typeof PROJECT_REVIEW_STATUSES)[number];
+
+/**
+ * An entry the platform published in a conversation on its own, rather than a
+ * message somebody wrote.
+ *
+ * It sits in the same list as the messages so it keeps its place in time, and
+ * carries the detail the card is built from instead of a written body.
+ */
+export interface ReviewSystemEvent {
+ /** Risk the area held before the change. */
+ from: ProjectReviewRisk;
+ /** Risk the area holds after the change, which the card announces. */
+ to: ProjectReviewRisk;
+}
+
+/**
+ * A message exchanged between the promoter and the consultant admin about one area.
*
* NOTE: this is not part of the OpenAPI spec — the v4 API exposes no review
- * resources yet, so `src/openapi/client` has no generated equivalent. Replace
- * this interface with the generated type once the API ships those endpoints.
+ * resources yet, so `src/openapi/client` has no generated equivalent. Replace this
+ * interface with the generated type once the API ships those endpoints.
*/
-export interface ProjectReviewArea {
+export interface ReviewComment {
+ id: number;
+ /** IRI of the User who wrote the message. */
+ author: string;
+ /** IRI of the ReviewArea this message belongs to. */
+ area: string;
+ /**
+ * Message text. Stays empty on system entries, which read their card off
+ * {@link ReviewComment.system} so the wording follows the active locale.
+ */
+ body: string;
+ /** ISO 8601 date, provided by the API `DateCreatedTrait`. */
+ dateCreated: string;
+ /** ISO 8601 date, provided by the API `DateUpdatedTrait`. */
+ dateUpdated: string;
+ /** Set only on entries published by the platform; absent on messages. */
+ system?: ReviewSystemEvent;
+}
+
+/**
+ * A reviewable area of a project, that is, one of the risks the consultant
+ * assesses. Each area carries its own conversation between promoter and admin.
+ *
+ * NOTE: this is not part of the OpenAPI spec — see {@link ReviewComment}.
+ */
+export interface ReviewArea {
+ id: number;
+ /** IRI of the ProjectReview this area belongs to. */
+ review: string;
/** Name of the reviewed area. */
title: string;
/** Reviewer's summary of the findings for this area. */
summary: string;
- /** Risk assessed for this area, if it has been evaluated. */
- risk?: ProjectReviewRisk;
- /** Number of chat messages exchanged about this area. */
- chatCount?: number;
- /** ISO 8601 date of the last message in this area's chat. */
- lastActivity?: string;
+ /** Risk assessed for this area, null while it has not been evaluated. */
+ risk: ProjectReviewRisk | null;
+ comments: ReviewComment[];
+}
+
+/**
+ * A review of a project carried out by a consultant admin.
+ *
+ * NOTE: this is not part of the OpenAPI spec — see {@link ReviewComment}.
+ */
+export interface ProjectReview {
+ id: number;
+ /** IRI of the reviewed Project. */
+ project: string;
+ /** IRI of the User acting as consultant. */
+ reviewer: string;
+ type: ProjectReviewType;
+ areas: ReviewArea[];
+}
+
+/**
+ * The subset of User data a chat bubble needs, resolved from an author IRI.
+ */
+export interface ReviewAuthor {
+ /** IRI of the User resource. */
+ author: string;
+ handle: string;
+ displayName: string;
+ avatar?: string;
}
diff --git a/src/utils/adminTable.ts b/src/utils/adminTable.ts
index 9bf180a7..835a39f2 100644
--- a/src/utils/adminTable.ts
+++ b/src/utils/adminTable.ts
@@ -12,10 +12,10 @@ export function isValidItemsPerPage(value: number): value is AdminItemsPerPage {
return (ADMIN_ITEMS_PER_PAGE_OPTIONS as readonly number[]).includes(value);
}
-export function getInitialItemsPerPage(): AdminItemsPerPage {
+export function getInitialItemsPerPage(cookie = ADMIN_ITEMS_PER_PAGE_COOKIE): AdminItemsPerPage {
if (typeof document === "undefined") return DEFAULT_ITEMS_PER_PAGE;
- const raw = getCookie(ADMIN_ITEMS_PER_PAGE_COOKIE);
+ const raw = getCookie(cookie);
if (!raw) return DEFAULT_ITEMS_PER_PAGE;
const parsed = Number(raw);
diff --git a/src/utils/adminTableState.svelte.ts b/src/utils/adminTableState.svelte.ts
index 21ba9c92..aa325bb4 100644
--- a/src/utils/adminTableState.svelte.ts
+++ b/src/utils/adminTableState.svelte.ts
@@ -1,24 +1,17 @@
import {
ADMIN_ITEMS_PER_PAGE_COOKIE,
- DEFAULT_ITEMS_PER_PAGE,
+ getInitialItemsPerPage,
isValidItemsPerPage,
type AdminItemsPerPage,
} from "./adminTable";
-import { getCookie, setCookie } from "./cookies";
+import { setCookie } from "./cookies";
-function getInitialItemsPerPage(): AdminItemsPerPage {
- if (typeof document === "undefined") return DEFAULT_ITEMS_PER_PAGE;
-
- const raw = getCookie(ADMIN_ITEMS_PER_PAGE_COOKIE);
- if (!raw) return DEFAULT_ITEMS_PER_PAGE;
-
- const parsed = Number(raw);
- return isValidItemsPerPage(parsed) ? parsed : DEFAULT_ITEMS_PER_PAGE;
-}
-
-export function useAdminTableState(initialSort: T) {
+export function useAdminTableState(
+ initialSort: T,
+ cookie = ADMIN_ITEMS_PER_PAGE_COOKIE,
+) {
let currentPage = $state(1);
- let itemsPerPage = $state(getInitialItemsPerPage());
+ let itemsPerPage = $state(getInitialItemsPerPage(cookie));
let selectedSort = $state(initialSort);
let isLoading = $state(false);
let isFirstLoad = $state(true);
@@ -67,7 +60,7 @@ export function useAdminTableState(initialSort: T) {
handleItemsPerPageChange(perPage: number) {
if (isValidItemsPerPage(perPage)) {
itemsPerPage = perPage;
- setCookie(ADMIN_ITEMS_PER_PAGE_COOKIE, String(perPage));
+ setCookie(cookie, String(perPage));
currentPage = 1;
}
},
diff --git a/src/utils/campaign.ts b/src/utils/campaign.ts
index e69de29b..9fd58e49 100644
--- a/src/utils/campaign.ts
+++ b/src/utils/campaign.ts
@@ -0,0 +1,29 @@
+import type { ProjectCalendar } from "../openapi/client";
+
+const DAY_MS = 1000 * 60 * 60 * 24;
+
+/** End date of the round in progress (minimum, then optimum), or `undefined` once both have passed. */
+export function getCurrentDeadline(calendar: ProjectCalendar): Date | undefined {
+ const now = new Date();
+
+ const minimum = new Date(calendar.minimum!);
+ if (now < minimum) {
+ return minimum;
+ }
+
+ if (!calendar.optimum) {
+ return undefined;
+ }
+
+ const optimum = new Date(calendar.optimum);
+ if (now < optimum) {
+ return optimum;
+ }
+
+ return undefined;
+}
+
+export function getDaysRemaining(calendar?: ProjectCalendar): number | undefined {
+ const deadline = calendar?.minimum ? getCurrentDeadline(calendar) : undefined;
+ return deadline ? Math.ceil((deadline.getTime() - Date.now()) / DAY_MS) : undefined;
+}
diff --git a/src/utils/countries.ts b/src/utils/countries.ts
new file mode 100644
index 00000000..c0c5d9c7
--- /dev/null
+++ b/src/utils/countries.ts
@@ -0,0 +1,15 @@
+import iso3166 from "iso-3166-2";
+
+/**
+ * Every country as `{ code, name }`, sorted by name in the given language, ready to feed a `Select`.
+ *
+ * The API stores and validates the country as an ISO 3166-1 alpha-2 string but exposes no list of
+ * them, so the options are built from the ISO data here and the names localized with `Intl`.
+ */
+export function getCountries(lang: Intl.LocalesArgument): { code: string; name: string }[] {
+ const names = new Intl.DisplayNames(lang, { type: "region" });
+
+ return Object.keys(iso3166.data)
+ .map((code) => ({ code, name: names.of(code) ?? code }))
+ .sort((a, b) => a.name.localeCompare(b.name, lang));
+}
diff --git a/src/utils/dates.ts b/src/utils/dates.ts
index b290015e..b67a1f38 100644
--- a/src/utils/dates.ts
+++ b/src/utils/dates.ts
@@ -10,6 +10,21 @@ export function formatDate(date: Date, locale: Locale) {
return date.toLocaleDateString(locale, options);
}
+/**
+ * The clock time a moment fell on, without the date.
+ * @param date The moment to write out
+ * @param locale Active locale, which sets the time separator
+ * @returns The hours and minutes, as "HH:mm"
+ */
+export function formatTime(date: Date, locale: Locale) {
+ const options: Intl.DateTimeFormatOptions = {
+ hour: "2-digit",
+ minute: "2-digit",
+ };
+
+ return date.toLocaleTimeString(locale, options);
+}
+
/**
* The first instant of the day for the given date (midnight, 00:00).
* @param date The date to normalize
diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts
new file mode 100644
index 00000000..ef484ba6
--- /dev/null
+++ b/src/utils/debounce.ts
@@ -0,0 +1,16 @@
+/**
+ * Delays `fn` until `ms` have passed without another call. Each call cancels
+ * the pending one; `cancel()` drops it without running.
+ */
+export function debounce(fn: (...args: A) => void, ms = 300) {
+ let timer: ReturnType | undefined;
+
+ const debounced = (...args: A) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn(...args), ms);
+ };
+
+ debounced.cancel = () => clearTimeout(timer);
+
+ return debounced;
+}
diff --git a/src/utils/hydra.ts b/src/utils/hydra.ts
index 65288cf9..fef19e66 100644
--- a/src/utils/hydra.ts
+++ b/src/utils/hydra.ts
@@ -19,3 +19,16 @@ export function toCollectionItems(collection: unknown): T[] {
return [];
}
+
+export function getCollectionTotalItems(collection: unknown): number {
+ if (collection && typeof collection === "object" && !Array.isArray(collection)) {
+ const record = collection as Record;
+ const totalItems = record.totalItems ?? record["hydra:totalItems"];
+
+ if (typeof totalItems === "number") {
+ return totalItems;
+ }
+ }
+
+ return toCollectionItems(collection).length;
+}
diff --git a/src/utils/lang.ts b/src/utils/lang.ts
index 5d75f708..aad2de2b 100644
--- a/src/utils/lang.ts
+++ b/src/utils/lang.ts
@@ -1,8 +1,13 @@
export function getLanguageDisplayName(lang: string): string | undefined {
- const displayNames = new Intl.DisplayNames(lang, { type: "language" });
- const displayName = displayNames.of(lang)!;
+ let displayName: string | undefined;
- if (displayName === lang) return;
+ try {
+ displayName = new Intl.DisplayNames(lang, { type: "language" }).of(lang);
+ } catch {
+ return;
+ }
+
+ if (!displayName || displayName === lang) return;
return displayName.charAt(0).toUpperCase() + displayName.slice(1);
}
diff --git a/src/utils/projectStatus.ts b/src/utils/projectStatus.ts
index e8f9cf28..22752937 100644
--- a/src/utils/projectStatus.ts
+++ b/src/utils/projectStatus.ts
@@ -1,3 +1,5 @@
+import type { Project } from "../openapi/client/index.ts";
+
/**
* Public project statuses for the public search page.
*
@@ -61,3 +63,13 @@ export function constrainToPublicStatuses(statuses: string[] = []): string[] {
const valid = statuses.filter((status) => PUBLIC_API_STATUSES.has(status));
return valid.length ? valid : expandPublicStatuses(ALL_PUBLIC_STATUSES);
}
+
+/** Campaign review statuses: listed in the admin review page and selectable in its dropdown. */
+export const CAMPAIGN_REVIEW_STATUSES: NonNullable[] = [
+ "to_campaign_review",
+ "in_campaign_review",
+ "in_campaign_review.to_review",
+ "in_campaign_review.to_change",
+ "campaign_review.rejected",
+ "to_campaign",
+];
diff --git a/src/utils/review.ts b/src/utils/review.ts
new file mode 100644
index 00000000..49b076df
--- /dev/null
+++ b/src/utils/review.ts
@@ -0,0 +1,76 @@
+import { startOfDay } from "./dates";
+import { extractId } from "./extractId";
+
+import type { ProjectReviewRisk, ProjectReviewStatus, ReviewComment } from "../types/projectReview";
+
+/**
+ * Whether two references point at the same User.
+ *
+ * IRIs arrive both absolute and relative depending on who wrote them — the API builds
+ * them from its collection URL, the placeholders spell them out — so they are compared
+ * by id instead of as strings.
+ * @param first One User IRI
+ * @param second Another User IRI
+ * @returns True when both references resolve to the same id
+ */
+export function isSameAuthor(first: string, second: string): boolean {
+ return first === second || extractId(first) === extractId(second);
+}
+
+/** Every risk a reviewable area can be assessed with, from least to most severe. */
+export const REVIEW_RISKS: ProjectReviewRisk[] = ["low", "medium", "high"];
+
+/** Tag variant each risk level is painted with, matching `domain.review.risks.*`. */
+export const RISK_TAG_VARIANTS: Record = {
+ low: "success",
+ medium: "warning",
+ high: "error",
+};
+
+/**
+ * Translation key of each review status.
+ *
+ * The API writes the status of a review with a dot separator (`campaign_review.rejected`)
+ * while the translations folder names it with an underscore, hence the explicit map.
+ */
+export const PROJECT_REVIEW_STATUS_LABELS: Record = {
+ in_campaign_review: "domain.project.status.in_campaign_review",
+ "in_campaign_review.to_change": "domain.project.status.in_campaign_review_to_change",
+ "in_campaign_review.to_review": "domain.project.status.in_campaign_review_to_review",
+ "campaign_review.rejected": "domain.project.status.campaign_review_rejected",
+};
+
+/**
+ * The moment a conversation was last touched, used for the activity line of a card.
+ * @param comments The messages of an area
+ * @returns The date of the newest message, or null when the conversation is empty
+ */
+export function lastActivity(comments: ReviewComment[]): Date | null {
+ if (!comments.length) return null;
+
+ return new Date(
+ comments.reduce(
+ (latest, comment) => Math.max(latest, new Date(comment.dateCreated).getTime()),
+ 0,
+ ),
+ );
+}
+
+/** The calendar day a moment belongs to, as the value two dates are compared by. */
+export function dayOf(date: Date): number {
+ return startOfDay(date).getTime();
+}
+
+/**
+ * Whether a message is the first one of a new day in the conversation.
+ *
+ * Chats break the thread with a date between days, so this marks where to place it.
+ * @param previous The message right before this one, if any
+ * @param current The message being rendered
+ * @returns True when there is no previous message or both fall on different days
+ */
+export function opensNewDay(previous: ReviewComment | undefined, current: ReviewComment): boolean {
+ if (!previous) return true;
+
+ return dayOf(new Date(previous.dateCreated)) !== dayOf(new Date(current.dateCreated));
+}
diff --git a/src/utils/richText.ts b/src/utils/richText.ts
index 1a9b7083..a8317d7f 100644
--- a/src/utils/richText.ts
+++ b/src/utils/richText.ts
@@ -9,6 +9,10 @@ export const ALIGNMENTS = ["left", "center", "right"] as const;
export type Alignment = (typeof ALIGNMENTS)[number];
+export const HEADING_LEVELS = [1, 2, 3, 4] as const;
+
+export type HeadingLevel = (typeof HEADING_LEVELS)[number];
+
/**
* Output formats RichTextEditor can read and emit. Tiptap JSON is the canonical one — every other
* format is derived from it, so anything the schema does not model is lost on the way out.
@@ -21,21 +25,21 @@ export type RichTextValue = F extends "json" ? JSONCon
* Schema shared by the editor and the converters below. Any extension added here changes the
* document schema, so RichTextEditor must build its editor from this same list — otherwise a
* document written by the editor could fail to round-trip through the converters.
+ *
+ * `TextStyle` and `FontSize` no longer have a toolbar control, but stay in the schema so documents
+ * saved with the old font-size selector still load.
*/
export const richTextExtensions = [
StarterKit.configure({
- heading: false,
- bulletList: false,
- orderedList: false,
- listItem: false,
+ heading: { levels: [...HEADING_LEVELS] },
strike: false,
code: false,
codeBlock: false,
- link: false,
underline: false,
+ link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
}),
TextAlign.configure({
- types: ["paragraph"],
+ types: ["heading", "paragraph"],
alignments: [...ALIGNMENTS],
defaultAlignment: "left",
}),
diff --git a/src/utils/searchParams.ts b/src/utils/searchParams.ts
new file mode 100644
index 00000000..a0ae8f32
--- /dev/null
+++ b/src/utils/searchParams.ts
@@ -0,0 +1,30 @@
+import type { ApiProjectsGetCollectionData } from "../openapi/client/types.gen";
+
+export type SearchFilters = NonNullable;
+
+export function parseSearchParamsFilters(searchParams: URLSearchParams): SearchFilters {
+ const filters: Record = {};
+
+ for (const key of new Set(searchParams.keys())) {
+ const values = searchParams.getAll(key);
+ filters[key] = values.length > 1 || key.endsWith("[]") ? values : values[0];
+ }
+
+ return filters as SearchFilters;
+}
+
+export function toSearchParams(filters: SearchFilters): URLSearchParams {
+ const params = new URLSearchParams();
+
+ for (const [key, value] of Object.entries(filters)) {
+ if (value == null || value === "") continue;
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => params.append(key, String(item)));
+ } else {
+ params.set(key, String(value));
+ }
+ }
+
+ return params;
+}
diff --git a/src/utils/socialLinks.ts b/src/utils/socialLinks.ts
new file mode 100644
index 00000000..109c483a
--- /dev/null
+++ b/src/utils/socialLinks.ts
@@ -0,0 +1,78 @@
+export const SOCIAL_NETWORKS = ["instagram", "facebook", "x", "linkedin"] as const;
+
+export type SocialNetwork = (typeof SOCIAL_NETWORKS)[number];
+
+export type SocialLinks = Record;
+
+// The API only tags known orgs (no X), so the network is resolved from the host here
+const NETWORK_DOMAINS: Record = {
+ instagram: ["instagram.com"],
+ facebook: ["facebook.com", "fb.com"],
+ x: ["x.com", "twitter.com"],
+ linkedin: ["linkedin.com"],
+};
+
+// Where a bare username is appended to build the profile link
+const NETWORK_PROFILE_URLS: Record = {
+ instagram: "https://www.instagram.com/",
+ facebook: "https://www.facebook.com/",
+ x: "https://x.com/",
+ linkedin: "https://www.linkedin.com/in/",
+};
+
+/**
+ * Adds `https://` when the scheme is missing, like the API does before storing a link.
+ */
+export function normalizeLinkUrl(value: string): string {
+ const url = value.trim();
+
+ return /^[a-z][a-z\d+.-]*:\/\//i.test(url) ? url : `https://${url}`;
+}
+
+export function getSocialNetwork(url: string): SocialNetwork | undefined {
+ let host: string;
+
+ try {
+ host = new URL(normalizeLinkUrl(url)).hostname.toLowerCase();
+ } catch {
+ return undefined;
+ }
+
+ return SOCIAL_NETWORKS.find((network) =>
+ NETWORK_DOMAINS[network].some((domain) => host === domain || host.endsWith(`.${domain}`)),
+ );
+}
+
+/**
+ * Turns a profile link or a bare username (`@` optional) into a link of the given network.
+ * @returns `undefined` when the value is neither
+ */
+export function toSocialLinkUrl(network: SocialNetwork, value: string): string | undefined {
+ const input = value.trim();
+ const linkNetwork = getSocialNetwork(input);
+
+ if (linkNetwork) {
+ return linkNetwork === network ? normalizeLinkUrl(input) : undefined;
+ }
+
+ const username = input.replace(/^@/, "");
+
+ return /^[\w.-]+$/.test(username) ? NETWORK_PROFILE_URLS[network] + username : undefined;
+}
+
+/**
+ * Picks the first link of each network from a User's links.
+ */
+export function toSocialLinks(urls: string[]): SocialLinks {
+ const links: SocialLinks = { instagram: "", facebook: "", x: "", linkedin: "" };
+
+ for (const url of urls) {
+ const network = getSocialNetwork(url);
+
+ if (network && !links[network]) {
+ links[network] = url;
+ }
+ }
+
+ return links;
+}
diff --git a/src/utils/territory.ts b/src/utils/territory.ts
index add33968..db5584db 100644
--- a/src/utils/territory.ts
+++ b/src/utils/territory.ts
@@ -2,6 +2,12 @@ import iso3166 from "iso-3166-2";
import type { Territory } from "../openapi/client";
+/**
+ * The API stores a territory without a country as this ISO 3166-1 "user assigned" code. It is what
+ * `Territory::__construct()` falls back to, so forms must treat it as no country at all.
+ */
+export const UNKNOWN_COUNTRY_CODE = "ZZ";
+
export function getTerritoryDisplayName(territory: Territory, lang: Intl.LocalesArgument): string {
const countryNames = new Intl.DisplayNames(lang, { type: "region" });
const country = countryNames.of(territory.country!);
diff --git a/src/validation/personalProfileValidation.ts b/src/validation/personalProfileValidation.ts
new file mode 100644
index 00000000..7bb067a9
--- /dev/null
+++ b/src/validation/personalProfileValidation.ts
@@ -0,0 +1,65 @@
+import { z } from "zod";
+
+import { isValidTaxId } from "../utils/taxId";
+
+const zRequiredField = () =>
+ z.string().refine((value) => value.trim().length > 0, {
+ error: "pages.me.manage.validation.required",
+ });
+
+/**
+ * The private half of the promoter profile: the `Person` record (names and tax id) plus the
+ * `Territory` living on the `User`. Every field here is only exposed by the API to the owner and
+ * to platform admins, which is what separates this form from the public one.
+ *
+ * `avatar`, `description` and `links` live on the `User` and are shared with the public section,
+ * but the pieces that edit them (image card, bio card and social networks) live here too, so they
+ * travel with the rest of the form.
+ *
+ * `type` is not editable: it decides where the tax id belongs, and changing the profile kind is
+ * done in the public section.
+ */
+export const zPersonalProfileForm = z
+ .object({
+ handle: z.string().regex(/^[a-z0-9_]{4,30}$/, {
+ error: "pages.me.manage.validation.handleInvalid",
+ }),
+ firstName: z.string(),
+ lastName: z.string(),
+ taxId: z.string(),
+ type: z.enum(["individual", "organization"]),
+ legalName: z.string(),
+ businessName: z.string(),
+ country: z.string().length(2).or(z.literal("")),
+ subLvl1: z.string(),
+ subLvl2: z.string(),
+ address: z.string(),
+ })
+ .superRefine((data, ctx) => {
+ const taxId = data.taxId.trim();
+
+ if (data.country && taxId && isValidTaxId(data.country, data.type, taxId) === false) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["taxId"],
+ message: "pages.me.manage.validation.taxIdInvalid",
+ });
+ }
+
+ if (data.type !== "organization") {
+ return;
+ }
+
+ // The API requires both fields on the Organization record
+ const result = z
+ .object({ legalName: zRequiredField(), taxId: zRequiredField() })
+ .safeParse({ legalName: data.legalName, taxId: data.taxId });
+
+ if (!result.success) {
+ for (const issue of result.error.issues) {
+ ctx.addIssue({ ...issue, path: [issue.path[0]!] });
+ }
+ }
+ });
+
+export type PersonalProfileForm = z.infer;
diff --git a/src/validation/publicProfileValidation.ts b/src/validation/publicProfileValidation.ts
new file mode 100644
index 00000000..b5b773a3
--- /dev/null
+++ b/src/validation/publicProfileValidation.ts
@@ -0,0 +1,31 @@
+import { z } from "zod";
+
+import { SOCIAL_NETWORKS, toSocialLinkUrl } from "../utils/socialLinks";
+
+export const zProfileForm = z
+ .object({
+ avatar: z.url().optional(),
+ description: z.string(),
+ links: z.object({
+ instagram: z.string(),
+ facebook: z.string(),
+ x: z.string(),
+ linkedin: z.string(),
+ }),
+ })
+ .superRefine((data, ctx) => {
+ // A username or a link of this network; one from another network would land in the wrong field
+ for (const network of SOCIAL_NETWORKS) {
+ const url = data.links[network].trim();
+
+ if (url && !toSocialLinkUrl(network, url)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["links", network],
+ message: "pages.me.manage.validation.linkInvalid",
+ });
+ }
+ }
+ });
+
+export type ProfileForm = z.infer;
diff --git a/static/dtf.md b/static/dtf.md
new file mode 100644
index 00000000..af79d638
--- /dev/null
+++ b/static/dtf.md
@@ -0,0 +1,51 @@
+# **Democratic Tech Fund**
+
+Our digital lives run on infrastructure we don’t own. The clouds where we work, the platforms where we organise, the social networks where we speak and the tools that sustain our communities are increasingly controlled by a handful of corporations.
+
+## **Democratic Tech Fund (DTF) exists to change that.**
+
+DTF is a growing federation of cooperatives, communities and public-interest organisations building **contra-infrastructure**: technology, knowledge and funding that communities can own, govern and sustain together.
+
+We don’t need to start from zero. Alternatives already exist. DTF connects them, strengthens them and helps them travel.
+
+## **BUILD → LEARN → FUND → FEDERATE**
+
+**Build** Cooperative, open and community-governed digital infrastructure.
+
+**Learn** Shared knowledge, peer learning and support for communities moving towards digital autonomy.
+
+**Fund** Collective funding mechanisms that turn community priorities into viable projects.
+
+**Federate** Shared infrastructure, resources and solidarity across communities, cities and borders.
+
+DTF is not only about replacing one piece of software with another. It is about reclaiming the infrastructures through which we **live, organise, care, remember and act together**.
+
+# **DTF BY GOTEO**
+
+**Crowdfunding the infrastructure we want to live with.**
+
+**DTF by Goteo is the crowdfunding arm of the Democratic Tech Fund.**
+
+Built with Fundación Platoniq and Goteo, it turns DTF’s mission into a practical funding pipeline: communities identify needs, projects emerge and are validated, citizens back them through crowdfunding, and institutions and funding partners can **match the crowd**.
+
+Instead of deciding behind closed doors what deserves funding, we use crowdfunding as a form of **distributed decision-making and civic validation**.
+
+## **COMMUNITY → PROJECT → CROWD → MATCH → COMMONS**
+
+A community identifies a need. A project proposes an alternative. The crowd decides to support it. Funders multiply that support through match-funding. Resources flow into infrastructure that creates collective value.
+
+Goteo brings more than fifteen years of experience in civic crowdfunding, with **over €25 million mobilised**, a community of more than **400,000 people**, and thousands of commons-oriented initiatives supported.
+
+**FROM FUNDING PROJECTS TO BUILDING ECOSYSTEMS**
+
+DTF by Goteo organises funding around thematic programmes or **Shelves**: curated spaces where communities, organisations and funders can gather projects around a shared challenge.
+
+A Shelf can support anything from community clouds and federated social media to cooperative platforms, democratic AI, collective archives and technologies that sustain **life and activism**.
+
+Organisations can also become **Shelf Stewards**: bring a community, define a challenge and contribute a funding pool. DTF and its partners can combine these resources with crowdfunding and match-funding so that every contribution helps unlock more collective investment.
+
+## **Don’t just fund another platform. Help build the digital commons we need.**
+
+**JOIN THE FEDERATION · START A SHELF · FUND A PROJECT · BACK A CAMPAIGN**
+
+## **The network is ours. Let’s take it back.**
diff --git a/wrangler.toml b/wrangler.toml
index 3a93c98f..cb718912 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -1,64 +1,37 @@
-name = "goteov4web-app"
+name = "dtfcrowd-app"
main = "@astrojs/cloudflare/entrypoints/server"
compatibility_date = "2025-06-26"
compatibility_flags = ["nodejs_compat"]
-workers_dev = false
-keep_vars = true
-
-
-# ============================================================================
-# LOCAL DEVELOPMENT
-# ============================================================================
[[d1_databases]]
binding = "DB"
-database_name = "goteov4web-db"
-database_id = "1f119ee7-358a-4eba-b076-40248d4f28f6"
+database_name = "dtfcrowd-db"
+database_id = "6c3e6748-7e5c-450d-9565-d24739442341"
migrations_dir = "db/migrations"
-
[assets]
binding = "ASSETS"
directory = "./dist"
-
[observability.logs]
enabled = true
-# ============================================================================
-# TESTING
-# ============================================================================
-
-[env.testing]
-name = "goteov4web-app-testing"
-
-routes = [
- { pattern = "testing.v4.goteo.org", zone_name = "goteo.org", custom_domain = true },
-]
-
-[[env.testing.d1_databases]]
-binding = "DB"
-database_name = "goteov4web-db-testing"
-database_id = "5f54dc4e-811b-438b-b83e-9acee224f250"
-migrations_dir = "db/migrations"
-
-
# ============================================================================
# STAGING
# ============================================================================
[env.staging]
-name = "goteov4web-app-staging"
+name = "dtfcrowd-app-staging"
routes = [
- { pattern = "staging.v4.goteo.org", zone_name = "goteo.org", custom_domain = true },
+ { pattern = "dtf-staging.v4.goteo.org", zone_name = "goteo.org", custom_domain = true },
]
[[env.staging.d1_databases]]
binding = "DB"
-database_name = "goteov4web-db-staging"
-database_id = "b6afee51-6e91-4e25-b7c0-fb9b166b4cda"
+database_name = "dtfcrowd-db-staging"
+database_id = "9c338a5d-f860-488c-b0c0-46b5b74f0492"
migrations_dir = "db/migrations"
@@ -67,14 +40,14 @@ migrations_dir = "db/migrations"
# ============================================================================
[env.prod]
-name = "goteov4web-app-prod"
+name = "dtfcrowd-app-prod"
routes = [
- { pattern = "v4.goteo.org", zone_name = "goteo.org", custom_domain = true },
+ { pattern = "dtf.v4.goteo.org", zone_name = "goteo.org", custom_domain = true },
]
[[env.prod.d1_databases]]
binding = "DB"
-database_name = "goteov4web-db-prod"
-database_id = "65fe9b71-9263-400e-ae7f-7d899493ee99"
+database_name = "dtfcrowd-db-prod"
+database_id = "b682c189-c6f2-4932-aae0-f08185efeba9"
migrations_dir = "db/migrations"