mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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
247 lines
7.5 KiB
TypeScript
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;
|
|
}
|