Skip to content

Commit 4d1f61e

Browse files
committed
add residential listing limit
1 parent 525ef28 commit 4d1f61e

12 files changed

Lines changed: 212 additions & 38 deletions

File tree

e2e/listings.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ test("listing type chooser routes signed-in hosts to the selected form", async (
6666
await expect(page.locator("#name")).toBeVisible();
6767
});
6868

69+
test("listing type chooser hides residential option at the residential limit", async ({
70+
page,
71+
}) => {
72+
await signIn(page, {
73+
email: DONOR_EMAIL,
74+
redirectTo: "/profile/listings/new?type=host",
75+
});
76+
77+
const chooser = page.getByTestId("listing-type-chooser");
78+
const communityOption = page.getByTestId("listing-type-option-community");
79+
const continueButton = page.getByTestId("listing-type-chooser-submit");
80+
81+
await expect(chooser).toBeVisible();
82+
await expect(page.getByTestId("listing-type-option-residential")).toHaveCount(
83+
0
84+
);
85+
await expect(communityOption).toBeVisible();
86+
await communityOption.click();
87+
await expect(continueButton).toBeEnabled();
88+
await continueButton.click();
89+
90+
await expect(page).toHaveURL(/\/profile\/listings\/new\/community$/);
91+
});
92+
6993
test("new listing form shows validation feedback when location is missing", async ({
7094
page,
7195
}) => {

messages/de.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
"resetPasswordSuccess": "Dein Passwort wurde aktualisiert. Zurück zum Kompostieren!",
9999
"savePhotosFailed": "Der Eintrag wurde erstellt, aber die Fotos konnten nicht gespeichert werden.",
100100
"signUpFailed": "Registrierung fehlgeschlagen",
101-
"tooManyListings": "Du hast die maximale Anzahl erlaubter Einträge erreicht. Lösche einen deiner aktuellen drei Einträge, um einen neuen zu erstellen.",
101+
"tooManyListings": "Du hast die maximale Anzahl erlaubter Einträge erreicht. Lösche einen deiner bestehenden Einträge, um einen neuen zu erstellen.",
102102
"tooManyMessages": "Du hast zu viele Nachrichten gesendet. Bitte versuche es später erneut.",
103103
"tooManyThreads": "Du hast in der letzten Stunde zu viele neue Unterhaltungen begonnen. Bitte versuche es später erneut.",
104104
"firstNameTooShort": "Bitte verwende mindestens 2 Zeichen für deinen Vornamen.",
@@ -329,6 +329,7 @@
329329
"hostTypeTitle": "Wo nimmst du Essensreste an?",
330330
"listingTypeLabel": "Eintragstyp",
331331
"hostTypeLabel": "Gastgebertyp",
332+
"noAvailableOptions": "Du hast die maximale Anzahl erlaubter Einträge erreicht.",
332333
"options": {
333334
"host": {
334335
"title": "Ich nehme Essensreste an",

messages/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
"resetPasswordSuccess": "Your password has been updated. Let’s get back to composting!",
9999
"savePhotosFailed": "Created listing but couldn’t save photos.",
100100
"signUpFailed": "Sign up failed",
101-
"tooManyListings": "You’ve reached the maximum number of listings allowed. Delete one of your current three to create a new one.",
101+
"tooManyListings": "You’ve reached the maximum number of listings allowed. Delete one of your existing listings to create a new one.",
102102
"tooManyMessages": "You’ve sent too many messages. Please try again later.",
103103
"tooManyThreads": "You’ve started too many new conversations in the last hour. Please try again later.",
104104
"firstNameTooShort": "Please use at least 2 characters for your first name.",
@@ -329,6 +329,7 @@
329329
"hostTypeTitle": "Where will you accept food scraps?",
330330
"listingTypeLabel": "Listing type",
331331
"hostTypeLabel": "Host type",
332+
"noAvailableOptions": "You’ve reached the maximum number of listings allowed.",
332333
"options": {
333334
"host": {
334335
"title": "I accept food scraps",

messages/es.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
"resetPasswordSuccess": "Tu contraseña ha sido actualizada. ¡Volvamos al compostaje!",
9999
"savePhotosFailed": "Se creó el anuncio, pero no se pudieron guardar las fotos.",
100100
"signUpFailed": "No se pudo completar el registro",
101-
"tooManyListings": "Has alcanzado el número máximo de anuncios permitidos. Elimina uno de tus tres anuncios actuales para crear uno nuevo.",
101+
"tooManyListings": "Has alcanzado el número máximo de anuncios permitidos. Elimina uno de tus anuncios existentes para crear uno nuevo.",
102102
"tooManyMessages": "Has enviado demasiados mensajes. Inténtalo de nuevo más tarde.",
103103
"tooManyThreads": "Has iniciado demasiadas conversaciones nuevas en la última hora. Inténtalo de nuevo más tarde.",
104104
"firstNameTooShort": "Usa al menos 2 caracteres para tu nombre.",
@@ -329,6 +329,7 @@
329329
"hostTypeTitle": "¿Dónde recibirás restos de comida?",
330330
"listingTypeLabel": "Tipo de anuncio",
331331
"hostTypeLabel": "Tipo de anfitrión",
332+
"noAvailableOptions": "Has alcanzado el número máximo de anuncios permitidos.",
332333
"options": {
333334
"host": {
334335
"title": "Acepto restos de comida",

messages/fr.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
"resetPasswordSuccess": "Votre mot de passe a été mis à jour. Retour au compostage !",
9999
"savePhotosFailed": "L’annonce a été créée, mais les photos n’ont pas pu être enregistrées.",
100100
"signUpFailed": "Échec de l’inscription",
101-
"tooManyListings": "Vous avez atteint le nombre maximal d’annonces autorisées. Supprimez-en une parmi vos trois annonces actuelles pour en créer une nouvelle.",
101+
"tooManyListings": "Vous avez atteint le nombre maximal d’annonces autorisées. Supprimez l’une de vos annonces existantes pour en créer une nouvelle.",
102102
"tooManyMessages": "Vous avez envoyé trop de messages. Veuillez réessayer plus tard.",
103103
"tooManyThreads": "Vous avez lancé trop de nouvelles conversations au cours de la dernière heure. Veuillez réessayer plus tard.",
104104
"firstNameTooShort": "Veuillez utiliser au moins 2 caractères pour votre prénom.",
@@ -329,6 +329,7 @@
329329
"hostTypeTitle": "Où acceptez-vous des restes alimentaires ?",
330330
"listingTypeLabel": "Type d’annonce",
331331
"hostTypeLabel": "Type d’hôte",
332+
"noAvailableOptions": "Vous avez atteint le nombre maximal d’annonces autorisées.",
332333
"options": {
333334
"host": {
334335
"title": "J’accepte des restes alimentaires",

messages/pt-BR.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
"resetPasswordSuccess": "A sua senha foi atualizada. Vamos voltar à compostagem!",
9999
"savePhotosFailed": "O anúncio foi criado, mas não foi possível salvar as fotos.",
100100
"signUpFailed": "Não foi possível concluir o cadastro",
101-
"tooManyListings": "Você atingiu o número máximo de anúncios permitidos. Exclua um dos seus três anúncios atuais para criar um novo.",
101+
"tooManyListings": "Você atingiu o número máximo de anúncios permitidos. Exclua um dos seus anúncios existentes para criar um novo.",
102102
"tooManyMessages": "Você enviou mensagens demais. Tente novamente mais tarde.",
103103
"tooManyThreads": "Você iniciou conversas demais na última hora. Tente novamente mais tarde.",
104104
"firstNameTooShort": "Use pelo menos 2 caracteres no seu nome.",
@@ -329,6 +329,7 @@
329329
"hostTypeTitle": "Onde você aceita restos de comida?",
330330
"listingTypeLabel": "Tipo de anúncio",
331331
"hostTypeLabel": "Tipo de anfitrião",
332+
"noAvailableOptions": "Você atingiu o número máximo de anúncios permitidos.",
332333
"options": {
333334
"host": {
334335
"title": "Eu aceito restos de comida",

src/app/(forms)/profile/listings/new/page.tsx

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { createClient } from "@/utils/supabase/server";
12
import FormHeader from "@/components/FormHeader";
23
import ListingTypeChooser from "@/components/ListingTypeChooser";
4+
import {
5+
MAX_LISTINGS_PER_USER,
6+
MAX_RESIDENTIAL_LISTINGS_PER_USER,
7+
} from "@/config/listingLimits";
38
import { getTranslations } from "next-intl/server";
49
import type { Metadata } from "next";
510

@@ -19,20 +24,50 @@ export default async function AddListingPage({
1924
const t = await getTranslations();
2025
const { type } = await searchParams;
2126
const isHostSelection = type === "host";
27+
const supabase = await createClient();
28+
const {
29+
data: { user },
30+
} = await supabase.auth.getUser();
2231

23-
const options = isHostSelection
24-
? [
25-
{
26-
key: "residential",
27-
title: t("Listings.new.options.residential.title"),
28-
description: t("Listings.new.options.residential.description"),
29-
},
30-
{
31-
key: "community",
32-
title: t("Listings.new.options.community.title"),
33-
description: t("Listings.new.options.community.description"),
34-
},
35-
]
32+
const [{ data: profile }, { data: listings }] = user
33+
? await Promise.all([
34+
supabase.from("profiles").select("is_admin").eq("id", user.id).single(),
35+
supabase.from("listings").select("type").eq("owner_id", user.id),
36+
])
37+
: [{ data: null }, { data: null }];
38+
39+
const isAdmin = profile?.is_admin === true;
40+
const totalListingCount = listings?.length ?? 0;
41+
const residentialListingCount =
42+
listings?.filter((listing) => listing.type === "residential").length ?? 0;
43+
const hasReachedListingLimit =
44+
!isAdmin && totalListingCount >= MAX_LISTINGS_PER_USER;
45+
const hasReachedResidentialListingLimit =
46+
!isAdmin && residentialListingCount >= MAX_RESIDENTIAL_LISTINGS_PER_USER;
47+
48+
const hostOptions = [
49+
...(!hasReachedListingLimit && !hasReachedResidentialListingLimit
50+
? [
51+
{
52+
key: "residential",
53+
title: t("Listings.new.options.residential.title"),
54+
description: t("Listings.new.options.residential.description"),
55+
},
56+
]
57+
: []),
58+
...(!hasReachedListingLimit
59+
? [
60+
{
61+
key: "community",
62+
title: t("Listings.new.options.community.title"),
63+
description: t("Listings.new.options.community.description"),
64+
},
65+
]
66+
: []),
67+
];
68+
69+
const listingOptions = hasReachedListingLimit
70+
? []
3671
: [
3772
{
3873
key: "host",
@@ -46,6 +81,8 @@ export default async function AddListingPage({
4681
},
4782
];
4883

84+
const options = isHostSelection ? hostOptions : listingOptions;
85+
4986
return (
5087
<>
5188
<FormHeader button="back">
@@ -60,6 +97,7 @@ export default async function AddListingPage({
6097
mode={isHostSelection ? "host" : "listing"}
6198
options={options}
6299
continueLabel={t("Actions.continue")}
100+
emptyMessage={t("Listings.new.noAvailableOptions")}
63101
ariaLabel={
64102
isHostSelection
65103
? t("Listings.new.hostTypeLabel")

src/components/ListingTypeChooser.tsx

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ type ListingTypeChooserProps = {
1818
mode: "listing" | "host";
1919
options: ListingTypeOption[];
2020
continueLabel: string;
21+
emptyMessage: string;
2122
ariaLabel: string;
2223
};
2324

2425
export default function ListingTypeChooser({
2526
mode,
2627
options,
2728
continueLabel,
29+
emptyMessage,
2830
ariaLabel,
2931
}: ListingTypeChooserProps) {
3032
const router = useRouter();
@@ -63,21 +65,25 @@ export default function ListingTypeChooser({
6365
data-testid="listing-type-chooser"
6466
data-hydrated={isHydrated ? "true" : "false"}
6567
>
66-
<RadioGroup
67-
value={selectedOption}
68-
onChange={(value) => setSelectedOption(value as ListingTypeOption)}
69-
aria-label={ariaLabel}
70-
>
71-
{options.map((option) => (
72-
<Radio
73-
key={option.key}
74-
value={option}
75-
data-testid={`listing-type-option-${option.key}`}
76-
title={option.title}
77-
description={option.description}
78-
/>
79-
))}
80-
</RadioGroup>
68+
{options.length > 0 ? (
69+
<RadioGroup
70+
value={selectedOption}
71+
onChange={(value) => setSelectedOption(value as ListingTypeOption)}
72+
aria-label={ariaLabel}
73+
>
74+
{options.map((option) => (
75+
<Radio
76+
key={option.key}
77+
value={option}
78+
data-testid={`listing-type-option-${option.key}`}
79+
title={option.title}
80+
description={option.description}
81+
/>
82+
))}
83+
</RadioGroup>
84+
) : (
85+
<p>{emptyMessage}</p>
86+
)}
8187

8288
<SubmitButton
8389
width="full"

src/components/ProfileListings/ProfileListings.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from "next/link";
22
import Avatar from "@/components/Avatar";
33
import Lozenge from "@/components/Lozenge";
4+
import { MAX_LISTINGS_PER_USER } from "@/config/listingLimits";
45
import { css, styled } from "next-yak";
56
import { theme } from "@/styles/theme.yak";
67
import type { Listing, ListingType } from "@/types/listing";
@@ -21,8 +22,6 @@ type ProfileListingsCopy = {
2122
addAnotherListing: string;
2223
};
2324

24-
const MAX_LISTINGS = 12; // TODO: Store this value on Supabase and use in the related RLS policy, so they are always in sync
25-
2625
const ListingsList = styled.ul`
2726
display: flex;
2827
flex-direction: column;
@@ -180,7 +179,7 @@ export default function ProfileListings({
180179
);
181180
})}
182181
{/* Only show the "add a/another listing" link if there are less than the maximum amount of allowed listings OR the user is an admin*/}
183-
{listings.length < MAX_LISTINGS || profile?.is_admin ? (
182+
{listings.length < MAX_LISTINGS_PER_USER || profile?.is_admin ? (
184183
<li>
185184
{listings.length === 0 ? (
186185
<AddYourFirstListingLink href="/profile/listings/new">

src/config/listingLimits.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const MAX_LISTINGS_PER_USER = 12;
2+
export const MAX_RESIDENTIAL_LISTINGS_PER_USER = 3;

0 commit comments

Comments
 (0)