Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/components/RejectionPage/RejectionPage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@
box-shadow: 0 5px 4px color.change(styles.$pink--500, $alpha: 0.25);

cursor: pointer;

&:disabled {
opacity: 0.6;
cursor: progress;
}
}

.rejection-page__return-button--retry {
background: styles.$blue--500;
box-shadow: 0 5px 4px color.change(styles.$blue--500, $alpha: 0.25);
}

.rejection-page__hint {
margin-top: styles.$spacing--xs;
font-style: italic;
font-size: styles.$text--sm;
opacity: 0.75;
}

.rejection-page__button-group {
display: flex;
flex-direction: column;
gap: styles.$spacing--xs;
align-items: center;
}

// default look is for very small screens and works its way up to match larger screen sizes (could theoretically do it the other way around)
Expand Down Expand Up @@ -134,6 +158,12 @@
align-items: flex-start; // prevent button from taking the whole space
}

.rejection-page__button-group {
flex-direction: row;
align-items: flex-start;
gap: styles.$spacing--base;
}

.rejection-page__logo-stan {
width: clamp(200px, 50vw, 1000px);
}
Expand Down
78 changes: 69 additions & 9 deletions src/components/RejectionPage/RejectionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {useTranslation} from "react-i18next";
import {useEffect, useState, useRef, useCallback} from "react";
import {useParams} from "react-router";
import {ScrumlrLogo} from "components/ScrumlrLogo";
import {API} from "api";
import {useAppDispatch} from "store";
import {permittedBoardAccess} from "store/features/board";
import StanLight from "assets/stan/Stan_Toilette_Light.svg?react";
import StanDark from "assets/stan/Stan_Toilette_Dark.svg?react";
import BackgroundFreeFormLight from "assets/pages/404/404_Background_light.svg?react";
Expand All @@ -11,8 +16,55 @@ type RejectionPageProps = {
status: "rejected" | "too_many_join_requests" | "banned";
};

// retry interval for banned users in milliseconds
const BANNED_RETRY_INTERVAL_MS = 30_000;

export const RejectionPage = ({status}: RejectionPageProps) => {
const {t} = useTranslation();
const dispatch = useAppDispatch();
const {boardId} = useParams<"boardId">();
const [isRetrying, setIsRetrying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

const isBanned = status === "banned";

// probes the join endpoint without putting the redux store into "pending"
// so the rejection page (and its retry button) stays mounted when the attempt fails.
const probeRejoin = useCallback(async () => {
if (!boardId) return;
try {
const result = await API.joinBoard(boardId);
if (result.status === "ACCEPTED") {
dispatch(permittedBoardAccess(boardId));
}
} catch {
// network/temporary error: stay on the page and let the next tick try again.
}
}, [boardId, dispatch]);

const tryRejoin = async () => {
setIsRetrying(true);
try {
await probeRejoin();
} finally {
// give the user some visual feedback before re-enabling the button
setTimeout(() => setIsRetrying(false), 1000);
}
};

// automatically poll for unban while banned
useEffect(() => {
if (!isBanned || !boardId) return undefined;

intervalRef.current = setInterval(probeRejoin, BANNED_RETRY_INTERVAL_MS);

return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isBanned, boardId, probeRejoin]);

return (
<div className="rejection-page__root">
<div className="rejection-page__background">
Expand All @@ -28,15 +80,23 @@ export const RejectionPage = ({status}: RejectionPageProps) => {
<main className="rejection-page__main">
<div className="rejection-page__content">
<div className="rejection-page__title">{t("RejectionPage.title")}</div>
<div className="rejection-page__description">{status === "banned" ? t("RejectionPage.banned") : t("RejectionPage.description")}</div>
<button
className="rejection-page__return-button"
onClick={() => {
window.location.pathname = "/";
}}
>
{t("RejectionPage.button")}
</button>
<div className="rejection-page__description">{isBanned ? t("RejectionPage.banned") : t("RejectionPage.description")}</div>
{isBanned && <div className="rejection-page__hint">{t("RejectionPage.bannedRetryHint")}</div>}
<div className="rejection-page__button-group">
{isBanned && (
<button className="rejection-page__return-button rejection-page__return-button--retry" onClick={tryRejoin} disabled={isRetrying}>
{isRetrying ? t("RejectionPage.retrying") : t("RejectionPage.retry")}
</button>
)}
<button
className="rejection-page__return-button"
onClick={() => {
window.location.pathname = "/";
}}
>
{t("RejectionPage.button")}
</button>
</div>
</div>
<div className="rejection-page__image-wrapper">
<StanLight className="rejection-page__logo-stan rejection-page__logo-stan--light" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const Participants = () => {
.every((s) => participant.user.name.toLowerCase().includes(s))
)
.filter((participant) => participant.role === permissionFilter || permissionFilter === "ALL")
.filter((participant) => participant.connected === onlineFilter)
.filter((participant) => participant.banned || participant.connected === onlineFilter)
.map((participant) => (
<li data-clarity-mask="True" key={participant.user.id} className={classNames("participants__list-item", {banned: participant.banned})}>
<UserAvatar avatar={participant.user.avatar} className="participant__avatar" id={participant.user.id} ready={participant.ready} title={participant.user.name} />
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,9 @@
"title": "Ups!",
"description": "Es sieht so aus, als ob die Türen zu dieser Sitzung vorerst geschlossen bleiben. Wir freuen uns über dein Interesse, aber leider wurde dein Antrag auf Teilnahme abgelehnt.",
"banned": "Es tut uns Leid, aber es sieht so aus, als ob du von dieser Sitzung ausgeschlossen wurdest.",
"bannedRetryHint": "Falls du wieder zugelassen wurdest, kannst du erneut beitreten. Wir prüfen das auch automatisch alle 30 Sekunden.",
"retry": "Erneut beitreten",
"retrying": "Wird versucht...",
"button": "Zurück zur Startseite"
},
"Votes": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,9 @@
"title": "Oops!",
"description": "It looks like the doors to this session remain closed for now. We appreciate your interest, but unfortunately, your request to join has been declined.",
"banned": "We're sorry, but it looks like you have been excluded from this session.",
"bannedRetryHint": "If you have been re-admitted, you can try to rejoin. We will also check automatically every 30 seconds.",
"retry": "Try to rejoin",
"retrying": "Retrying...",
"button": "Back to Homepage"
},
"Votes": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,9 @@
"title": "Oups !",
"description": "Il semble que les portes de cette session restent fermées pour le moment. Nous apprécions votre intérêt, mais malheureusement, votre demande de participation a été refusée.",
"banned": "Nous sommes désolés, mais il semble que vous ayez été exclu de cette session.",
"bannedRetryHint": "Si vous avez été ré-admis, vous pouvez essayer de rejoindre la session. Nous vérifions également automatiquement toutes les 30 secondes.",
"retry": "Réessayer de rejoindre",
"retrying": "Tentative en cours...",
"button": "Retour à la page d'accueil"
},
"Votes": {
Expand Down
Loading