1
0
mirror of https://github.com/Unleash/unleash.git synced 2026-01-05 20:06:22 +01:00

Feat/feature flag splash (#11157)

This PR sets up the new splash overlay behind a feature flag and adds
the video as an asset
This commit is contained in:
Fredrik Strand Oseberg 2025-12-17 12:24:27 +01:00 committed by GitHub
parent 8205a9d973
commit a770549fd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 128 additions and 110 deletions

View File

@ -11,7 +11,7 @@ import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer';
import { routes } from 'component/menu/routes';
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import { SplashPageRedirect } from 'component/splash/SplashPageRedirect/SplashPageRedirect';
import { SplashOverlay } from 'component/splash/SplashOverlay/SplashOverlay';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { MaintenanceBanner } from './maintenance/MaintenanceBanner.tsx';
@ -113,7 +113,7 @@ export const App = () => {
<FeedbackNPS openUrl='http://feedback.unleash.run' />
<SplashPageRedirect />
<SplashOverlay />
</StyledContainer>
</>
</Demo>

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
@ -9,12 +10,11 @@ import {
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ArticleIcon from '@mui/icons-material/Article';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { ReactComponent as UnleashLogo } from 'assets/img/logoDarkWithText.svg';
const YOUTUBE_VIDEO_ID = 'PLACEHOLDER_VIDEO_ID';
const DOCS_URL = 'https://docs.getunleash.io/reference/release-plans';
const RELEASE_NOTES_URL = 'https://docs.getunleash.io/release-notes';
const YOUTUBE_VIDEO_ID = '40IUj67e9Ew';
const DOCS_URL = 'https://docs.getunleash.io/concepts/impact-metrics';
const DialogCard = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
@ -35,7 +35,7 @@ const HeaderRow = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(2, 3),
padding: theme.spacing(1, 3),
borderBottom: `1px solid ${theme.palette.divider}`,
}));
@ -44,19 +44,18 @@ const StyledCloseButton = styled(IconButton)(({ theme }) => ({
}));
const StyledLogo = styled(UnleashLogo)(({ theme }) => ({
height: theme.spacing(5),
height: theme.spacing(6),
}));
const ContentContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(3),
padding: `${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3.5)} ${theme.spacing(3)}`,
overflowY: 'auto',
flex: 1,
}));
const StyledTitle = styled(Typography)(({ theme }) => ({
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.h1.fontSize,
fontWeight: theme.typography.fontWeightLight,
fontWeight: theme.typography.fontWeightBold,
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
}));
@ -69,7 +68,7 @@ const StyledDescription = styled(Typography)(({ theme }) => ({
const LinksRow = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(3),
marginBottom: theme.spacing(3),
marginBottom: theme.spacing(4),
}));
const StyledLink = styled('a')(({ theme }) => ({
@ -85,11 +84,11 @@ const StyledLink = styled('a')(({ theme }) => ({
},
}));
const YouTubeContainer = styled(Box)(({ theme }) => ({
const VideoContainer = styled(Box)(({ theme }) => ({
position: 'relative',
width: '100%',
paddingBottom: '56.25%',
marginBottom: theme.spacing(3),
marginBottom: theme.spacing(4),
borderRadius: theme.shape.borderRadiusLarge,
overflow: 'hidden',
backgroundColor: theme.palette.background.elevation1,
@ -103,6 +102,55 @@ const YouTubeContainer = styled(Box)(({ theme }) => ({
},
}));
const VideoThumbnail = styled('img')({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
});
const PlayOverlay = styled('button')({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
border: 'none',
background: 'transparent',
padding: 0,
outline: 'none',
'&:focus-visible > div': {
outline: '2px solid white',
outlineOffset: '2px',
},
});
const PlayButton = styled(Box)(({ theme }) => ({
width: theme.spacing(10),
height: theme.spacing(10),
borderRadius: '50%',
backgroundColor: theme.palette.primary.main,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: theme.shadows[4],
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: theme.shadows[8],
},
'& svg': {
fontSize: theme.spacing(5),
color: theme.palette.common.white,
},
}));
const ActionsRow = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
@ -117,12 +165,17 @@ export const ReleaseManagementSplash = ({
onClose,
}: ReleaseManagementSplashProps) => {
const navigate = useNavigate();
const [isPlaying, setIsPlaying] = useState(false);
const handleGetStarted = () => {
onClose();
navigate('/release-templates');
};
const handlePlayClick = () => {
setIsPlaying(true);
};
return (
<DialogCard>
<HeaderRow>
@ -159,24 +212,34 @@ export const ReleaseManagementSplash = ({
<OpenInNewIcon fontSize='small' />
View documentation
</StyledLink>
<StyledLink
href={RELEASE_NOTES_URL}
target='_blank'
rel='noopener noreferrer'
>
<ArticleIcon fontSize='small' />
Release notes
</StyledLink>
</LinksRow>
<YouTubeContainer>
<iframe
src={`https://www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`}
title='Release management introduction video'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
/>
</YouTubeContainer>
<VideoContainer>
{isPlaying ? (
<iframe
src={`https://www.youtube.com/embed/${YOUTUBE_VIDEO_ID}?autoplay=1`}
title='Release management introduction video'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
/>
) : (
<>
<VideoThumbnail
src={`https://img.youtube.com/vi/${YOUTUBE_VIDEO_ID}/hqdefault.jpg`}
alt='Video thumbnail'
/>
<PlayOverlay
onClick={handlePlayClick}
aria-label='Play video'
type='button'
>
<PlayButton>
<PlayArrowIcon />
</PlayButton>
</PlayOverlay>
</>
)}
</VideoContainer>
<ActionsRow>
<Button
@ -184,7 +247,7 @@ export const ReleaseManagementSplash = ({
color='primary'
onClick={handleGetStarted}
>
Get started with release management
View the getting started guide
</Button>
<Button variant='text' onClick={onClose}>
Cancel

View File

@ -1,16 +1,12 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Modal, Backdrop, styled } from '@mui/material';
import Fade from '@mui/material/Fade';
import { useFlag } from '@unleash/proxy-client-react';
import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi';
import {
activeSplashIds,
splashIds,
type SplashId,
} from 'component/splash/splash';
import type { IAuthSplash } from 'hooks/api/getters/useAuth/useAuthEndpoint';
import { splashIds, type SplashId } from 'component/splash/splash';
import { ReleaseManagementSplash } from './ReleaseManagementSplash';
const TRANSITION_DURATION = 250;
@ -27,10 +23,6 @@ const ModalContent = styled('div')({
outline: 'none',
});
const hasSeenSplashId = (splashId: SplashId, splash: IAuthSplash): boolean => {
return Boolean(splash[splashId]);
};
const isKnownSplashId = (value: string): value is SplashId => {
return (splashIds as Readonly<string[]>).includes(value);
};
@ -41,43 +33,55 @@ export const SplashOverlay = () => {
const { splash, refetchSplash } = useAuthSplash();
const { setSplashSeen } = useSplashApi();
const [closedSplash, setClosedSplash] = useState(false);
const releaseManagementV3Enabled = useFlag('releaseManagementV3Splash');
const splashId = searchParams.get('splash');
const isKnownId = splashId ? isKnownSplashId(splashId) : false;
useEffect(() => {
if (!user || !splash || splashId) return;
if (!user || !splash || splashId || user.isAPI || closedSplash) return;
if (user.isAPI) return;
const unseenSplashId = activeSplashIds.find(
(id) => !hasSeenSplashId(id, splash),
const hasSeenReleaseManagementV3 = Boolean(
splash['release-management-v3'],
);
if (unseenSplashId) {
searchParams.set('splash', unseenSplashId);
if (releaseManagementV3Enabled && !hasSeenReleaseManagementV3) {
searchParams.set('splash', 'release-management-v3');
setSearchParams(searchParams, { replace: true });
}
}, [user, splash, splashId, searchParams, setSearchParams]);
}, [
user,
splash,
splashId,
searchParams,
setSearchParams,
releaseManagementV3Enabled,
closedSplash,
]);
useEffect(() => {
if (splashId && isKnownId) {
setSplashSeen(splashId)
const handleClose = () => {
const currentSplashId = splashId;
const currentIsKnownId = isKnownId;
setClosedSplash(true);
searchParams.delete('splash');
setSearchParams(searchParams, { replace: true });
if (currentSplashId && currentIsKnownId) {
setSplashSeen(currentSplashId)
.then(() => refetchSplash())
.catch(console.warn);
}
// eslint-disable-next-line react-hooks/exhaustive-deps refetch and setSplashSeen are not stable references
}, [splashId, isKnownId]);
const handleClose = () => {
searchParams.delete('splash');
setSearchParams(searchParams, { replace: true });
};
if (!splashId) return null;
const getSplashContent = () => {
switch (splashId) {
case 'release-management':
case 'release-management-v3':
return <ReleaseManagementSplash onClose={handleClose} />;
default:
return null;

View File

@ -2,7 +2,6 @@ import { useNavigate, Navigate } from 'react-router-dom';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi';
import { SplashPageOperators } from 'component/splash/SplashPageOperators';
import { SplashPageReleaseManagement } from 'component/splash/SplashPageReleaseManagement';
import { useEffect } from 'react';
import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
import { splashIds, type SplashId } from 'component/splash/splash';

View File

@ -1,45 +0,0 @@
import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
import { useLocation, Navigate } from 'react-router-dom';
import { matchPath } from 'react-router';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import type { IAuthSplash } from 'hooks/api/getters/useAuth/useAuthEndpoint';
import { activeSplashIds, type SplashId } from 'component/splash/splash';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
export const SplashPageRedirect = () => {
const { pathname } = useLocation();
const { user } = useAuthUser();
const { splash } = useAuthSplash();
const { uiConfig, loading } = useUiConfig();
if (!user || !splash || !uiConfig || loading) {
// Wait for everything to load.
return null;
}
if (matchPath('/splash/:splashId', pathname)) {
// We've already redirected to the splash page.
return null;
}
// Read-only API users should never see splash screens
// since they don't have access to mark them as seen.
if (user.isAPI) {
return null;
}
// Find the splash page to show (if any).
const showSplashId = activeSplashIds.find((splashId) => {
return !hasSeenSplashId(splashId, splash);
});
if (!showSplashId) {
return null;
}
return <Navigate to={`/splash/${showSplashId}`} replace />;
};
const hasSeenSplashId = (splashId: SplashId, splash: IAuthSplash): boolean => {
return Boolean(splash[splashId]);
};

View File

@ -1,7 +1,4 @@
// All known splash IDs.
export const splashIds = ['operators'] as const;
// Active splash IDs that may be shown to the user.
export const activeSplashIds: SplashId[] = [];
export const splashIds = ['operators', 'release-management-v3'] as const;
export type SplashId = (typeof splashIds)[number];