Files
PleinR/src/lib/queries.ts
T
Claude d99ee7ec07 Promotions : logo de l'adhérent dans l'angle des cartes
Le logo du commerce identifie l'offre avant même qu'on lise le nom. Il est
posé en pastille ronde dans l'angle haut-gauche de la carte, la catégorie se
décalant juste à côté sur la même ligne.

- `src/components/MemberAvatar.tsx` : logo en `contain` sur fond blanc (les
  logos d'adhérents sont des marques sur fond clair, un `cover` les rognerait),
  repli sur les initiales colorées quand la fiche n'a pas de logo.
- Appliqué à la carte d'accueil, à la carte de la fiche adhérent, à l'aperçu
  en direct de l'espace adhérent et à la carte de modération : même composition
  partout, l'aperçu montre donc bien ce que verra le visiteur.
- `getLivePromotions` remonte `members.logo_url` ; la page espace le passe au
  formulaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:02:53 +00:00

247 lines
7.5 KiB
TypeScript

import { and, asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
/**
* Promotion réellement visible par le public : validée **et** dans sa période
* de validité.
*
* Les deux bornes sont facultatives — une offre sans date reste affichée sans
* limite. La journée de référence est celle de l'association, pas celle du
* serveur : un conteneur en UTC retirerait sinon une offre « valable jusqu'au
* 30 juin » deux heures trop tôt.
*/
const VISIBLE_PROMO = and(
eq(promotions.status, "live"),
sql`(${promotions.startsOn} is null or ${promotions.startsOn} <= (current_timestamp at time zone 'Europe/Paris')::date)`,
sql`(${promotions.endsOn} is null or ${promotions.endsOn} >= (current_timestamp at time zone 'Europe/Paris')::date)`
);
export async function getActiveMembersWithCategory() {
return db
.select({
id: members.id,
name: members.name,
description: members.description,
city: members.city,
address: members.address,
coverUrl: members.coverUrl,
logoUrl: members.logoUrl,
categoryLabel: categories.label,
accent: categories.accent,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.status, "active"))
.orderBy(asc(members.name));
}
export async function getAllCategories() {
return db
.select({ id: categories.id, label: categories.label, accent: categories.accent })
.from(categories)
.orderBy(asc(categories.sort));
}
export async function getLiveBadgesByMember() {
return db
.select({ memberId: promotions.memberId, badge: promotions.badge })
.from(promotions)
.where(VISIBLE_PROMO);
}
export async function getCategoriesWithCounts(limit = 6) {
const rows = await db
.select({
id: categories.id,
slug: categories.slug,
label: categories.label,
accent: categories.accent,
tint: categories.tint,
sort: categories.sort,
memberCount: count(members.id),
})
.from(categories)
.leftJoin(
members,
and(eq(members.categoryId, categories.id), eq(members.status, "active"))
)
.groupBy(categories.id)
.orderBy(categories.sort)
.limit(limit);
return rows;
}
export async function getLivePromotions(limit = 6) {
const rows = await db
.select({
id: promotions.id,
title: promotions.title,
text: promotions.text,
category: promotions.category,
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(VISIBLE_PROMO)
.orderBy(desc(promotions.createdAt))
.limit(limit);
return rows;
}
export async function getPublicMember(id: number) {
const [m] = await db
.select({
id: members.id,
name: members.name,
email: members.email,
contactEmail: members.contactEmail,
description: members.description,
city: members.city,
address: members.address,
status: members.status,
logoUrl: members.logoUrl,
coverUrl: members.coverUrl,
phone: members.phone,
website: members.website,
postalCode: members.postalCode,
memberSince: members.memberSince,
hours: members.hours,
tags: members.tags,
categoryLabel: categories.label,
categorySlug: categories.slug,
accent: categories.accent,
tint: categories.tint,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.id, id));
return m ?? null;
}
export async function getMemberLivePromotions(memberId: number) {
return db
.select({
id: promotions.id,
title: promotions.title,
text: promotions.text,
category: promotions.category,
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
})
.from(promotions)
.where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
.orderBy(desc(promotions.createdAt));
}
// Tous les adhérents actifs, tournés équitablement : l'ordre pivote d'un cran
// par heure pour que chacun passe en tête à tour de rôle au fil du temps.
export async function getRotatingActiveMembers() {
const rows = await db
.select({
id: members.id,
name: members.name,
description: members.description,
city: members.city,
address: members.address,
coverUrl: members.coverUrl,
logoUrl: members.logoUrl,
categoryLabel: categories.label,
accent: categories.accent,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.status, "active"))
.orderBy(asc(members.id));
const n = rows.length;
if (n <= 1) return rows;
const offset = Math.floor(Date.now() / 3_600_000) % n;
return [...rows.slice(offset), ...rows.slice(0, offset)];
}
export async function getHighlightedMembers(limit = 3) {
const rows = await db
.select({
id: members.id,
name: members.name,
description: members.description,
city: members.city,
address: members.address,
categoryLabel: categories.label,
accent: categories.accent,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(and(eq(members.highlighted, true), eq(members.status, "active")))
.limit(limit);
return rows;
}
/** Nombre d'adhérents validés (statut `active`) : affiché sur l'accueil. */
export async function getActiveMemberCount(): Promise<number> {
const [row] = await db
.select({ total: count(members.id) })
.from(members)
.where(eq(members.status, "active"));
return Number(row?.total ?? 0);
}
/** Une catégorie par son slug d'URL, ou `null`. */
export async function getCategoryBySlug(slug: string) {
const [row] = await db
.select({
id: categories.id,
slug: categories.slug,
label: categories.label,
accent: categories.accent,
tint: categories.tint,
})
.from(categories)
.where(eq(categories.slug, slug));
return row ?? null;
}
/** Adhérents actifs d'une catégorie, pour la page métier de l'annuaire. */
export async function getActiveMembersByCategory(categoryId: number) {
return db
.select({
id: members.id,
name: members.name,
description: members.description,
city: members.city,
address: members.address,
coverUrl: members.coverUrl,
logoUrl: members.logoUrl,
categoryLabel: categories.label,
accent: categories.accent,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(and(eq(members.status, "active"), eq(members.categoryId, categoryId)))
.orderBy(asc(members.name));
}
/** Catégories ayant au moins un adhérent actif, avec leur effectif. */
export async function getCategoriesInUse() {
const rows = await getCategoriesWithCounts(1000);
return rows.filter((c) => Number(c.memberCount) > 0);
}
/** Promotions réellement affichées aujourd'hui — le chiffre du tableau de bord. */
export async function countVisiblePromotions(): Promise<number> {
const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO);
return row?.n ?? 0;
}