Skip to content

Commit

Permalink
[FEAT] 추첨 결과를 마크다운 텍스트 형태로 복사할 수 있는 기능을 추가 (#145)
Browse files Browse the repository at this point in the history
* ✨ feat: "문제 목록 복사" 버튼을 추가하고, 클릭할 경우 마크다운 텍스트 형태의 문제 정보를 복사할 수 있는 기능을 구현

* 🛠 fix: 문제 카드 그리드가 overflow가 아닐 때 카드 조명 효과가 어색하게 잘리는 문제를 해결

* 🛠 fix: 효과음 버튼을 한 번도 누르지 않은 상태에서 효과음이 꺼져있을 때 소리가 나는 문제를 해결

* ✨ feat: GachaModalNotification 컴포넌트를 구현

* ✨ feat: "문제 목록 복사" 버튼을 누를 경우 알림창이 표시되도록 구현

* 🧪 test: 문제 정보를 마크다운 텍스트로 변환하는 도메인 로직에 대한 단위 테스트 추가

* 🛠 fix: 알림창의 레이아웃 크기가 모달과 맞지 않는 문제를 해결

- 알림창에 wrapper를 두고, wrapper의 position을 absolute로 설정
  • Loading branch information
wzrabbit authored Jan 9, 2025
1 parent a1d85d4 commit 6238512
Show file tree
Hide file tree
Showing 11 changed files with 254 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react';
import GachaModalNotification from './GachaModalNotification';

/**
* `GachaModalNotification`은 사용자가 즉석 추첨 모달의 추첨 결과 화면에서 하단의 메뉴 버튼을 눌렀을 때에 대한 시각적 피드백을 제공하기 위한 알림 컴포넌트입니다.
* 알림이 나타날 때 짧은 시간 동안 밝은 색으로 점멸하여 같은 버튼을 여러 번 눌러 같은 안내 메시지를 보여줘야 하는 상황에서도 사용자가 버튼을 여러 번 눌렀음을 알 수 있도록 구현하였습니다.
*/
const meta = {
title: 'components/GachaModalNotification',
component: GachaModalNotification,
argTypes: {
children: {
description: '알림 컴포넌트에 보여질 안내 메시지입니다.',
},
shouldFadeOut: {
description:
'등장 시 밝아졌다가 잠시 후 서서히 사라지는 애니메이션이 진행 중이어야 하는지의 여부입니다. 이 값을 `false`로 두어 메시지를 보이게 한 후, 즉시 `true`로 바꾸면 메시지가 밝아졌다가 잠시 후 사라지므로 종합적으로 메시지가 등장했다 사라지는 효과를 부여할 수 있습니다.',
},
},
} satisfies Meta<typeof GachaModalNotification>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
children: '테스트 알림 메시지입니다.',
shouldFadeOut: false,
},
};
57 changes: 57 additions & 0 deletions components/GachaModalNotification/GachaModalNotification.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { styled, keyframes } from 'styled-components';

export const highlightAndFadeOut = keyframes`
0% {
opacity: 1;
filter: brightness(200%);
}
20% {
opacity: 1;
filter: brightness(100%);
}
80% {
opacity: 1;
filter: brightness(100%);
}
100% {
opacity: 0;
filter: brightness(100%);
}
`;

export const Container = styled.div`
display: flex;
column-gap: 5px;
justify-content: center;
align-items: center;
width: 100%;
height: 20px;
user-select: none;
&.fading {
animation: ${highlightAndFadeOut} 2s forwards;
}
`;

export const NotificationIconWrapper = styled.div`
width: 20px;
height: 20px;
& > svg {
width: 100%;
height: 100%;
color: ${({ theme }) => theme.color.GOLD};
}
`;

export const NotificationText = styled.div`
font-size: 16px;
color: ${({ theme }) => theme.color.GOLD};
font-weight: 600;
`;
24 changes: 24 additions & 0 deletions components/GachaModalNotification/GachaModalNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CheckIcon } from '@/assets/svg';
import * as S from './GachaModalNotification.styled';

interface GachaModalNotificationProps {
children: string;
shouldFadeOut: boolean;
}

const GachaModalNotification = (props: GachaModalNotificationProps) => {
const { children, shouldFadeOut } = props;

return (
<S.Container className={shouldFadeOut ? 'fading' : ''}>
{children !== '' && (
<S.NotificationIconWrapper>
<CheckIcon />
</S.NotificationIconWrapper>
)}
<S.NotificationText>{children}</S.NotificationText>
</S.Container>
);
};

export default GachaModalNotification;
3 changes: 3 additions & 0 deletions components/GachaModalNotification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import GachaModalNotification from './GachaModalNotification';

export default GachaModalNotification;
11 changes: 8 additions & 3 deletions components/ProblemCardGrid/ProblemCardGrid.styled.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { styled } from 'styled-components';

export const Container = styled.div<{ $visible: boolean }>`
export const Container = styled.div<{
$visible: boolean;
$overflowY: 'visible' | 'auto';
}>`
display: flex;
visibility: ${({ $visible }) => ($visible ? 'visible' : 'hidden')};
overflow-x: hidden;
overflow-y: auto;
overflow-x: visible;
overflow-y: ${({ $overflowY }) => $overflowY};
justify-content: center;
width: 100%;
Expand All @@ -31,6 +34,7 @@ export const DynamicGrid = styled.div.attrs<{ $gap: number }>(({ $gap }) => ({
style: { rowGap: `${$gap}px` },
}))`
display: flex;
overflow: visible;
flex-direction: column;
justify-content: center;
align-items: center;
Expand All @@ -41,6 +45,7 @@ export const Row = styled.div.attrs<{ $gap: number }>(({ $gap }) => ({
style: { columnGap: `${$gap}px` },
}))`
display: flex;
overflow: visible;
justify-content: center;
width: 100%;
Expand Down
6 changes: 5 additions & 1 deletion components/ProblemCardGrid/ProblemCardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ const ProblemCardGrid = (props: ProblemCardGridProps) => {
let renderingCardIndex = 0;

return (
<S.Container ref={cardGridRef} $visible={isLoaded}>
<S.Container
ref={cardGridRef}
$visible={isLoaded}
$overflowY={isOverflow ? 'auto' : 'visible'}
>
{isOverflow ? (
<S.StaticGrid
$width={cardGridInfo.innerGridWidth}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,15 @@ export const ProblemCardGridWrapper = styled.div`
animation: ${zoomIn} cubic-bezier(0.165, 0.84, 0.44, 1) 0.7s 0.3s forwards;
`;

export const GachaModalNotificationWrapper = styled.div`
position: absolute;
left: 0;
bottom: 65px;
width: 100%;
height: 20px;
`;

export const ResultBottomControlList = styled.div`
display: flex;
justify-content: center;
Expand Down
25 changes: 23 additions & 2 deletions components/RandomDefenseGachaModal/RandomDefenseGachaModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
DicesIcon,
VolumeOffIcon,
VolumeOnIcon,
CopyIcon,
} from '@/assets/svg';
import { hiddenTierBadgeIcon, tier1BadgeIcon } from '@/assets/png';
import { theme } from '@/styles/theme';
import type { FilledSlot } from '@/types/randomDefense';
import CardBox from '@/components/CardBox';
import ProblemCardGrid from '@/components/ProblemCardGrid';
import useRandomDefenseGachaModal from '@/hooks/gacha/useRandomDefenseGachaModal';
import GachaModalNotification from '../GachaModalNotification/GachaModalNotification';

interface RandomDefenseGachaModalProps {
open: boolean;
Expand All @@ -35,13 +37,16 @@ const RandomDefenseGachaModal = (props: RandomDefenseGachaModalProps) => {
errorDescriptions,
isTierHidden,
isAudioMuted,
setGachaStatus,
notificationMessage,
shouldNotificationFadeOut,
restartGacha,
toggleIsTierHidden,
toggleIsAudioMuted,
playCardSlideAudio,
playGachaAudio,
stopGachaAudio,
copyProblemInfosMarkdownToClipboard,
showResultScreenAndResetNotificationMessage,
} = useRandomDefenseGachaModal({ open, slot, problemCount });

return (
Expand Down Expand Up @@ -76,7 +81,7 @@ const RandomDefenseGachaModal = (props: RandomDefenseGachaModalProps) => {
isTierHidden={isTierHidden}
cardRanks={previewCardRanks}
onFirstClick={playGachaAudio}
onOpenAnimationEnd={() => setGachaStatus('showingResult')}
onOpenAnimationEnd={showResultScreenAndResetNotificationMessage}
/>
</S.CardBoxWrapper>
<S.BottomControlList>
Expand Down Expand Up @@ -128,6 +133,7 @@ const RandomDefenseGachaModal = (props: RandomDefenseGachaModalProps) => {
</S.BottomControlList>
</S.ErrorScreen>
)}

{gachaStatus === 'showingResult' && (
<S.ResultScreen>
<S.ProblemCardGridWrapper>
Expand All @@ -137,7 +143,22 @@ const RandomDefenseGachaModal = (props: RandomDefenseGachaModalProps) => {
isTierHidden={isTierHidden}
/>
</S.ProblemCardGridWrapper>
<S.GachaModalNotificationWrapper>
<GachaModalNotification shouldFadeOut={shouldNotificationFadeOut}>
{notificationMessage}
</GachaModalNotification>
</S.GachaModalNotificationWrapper>
<S.ResultBottomControlList>
<IconButton
type="button"
name="문제 목록 복사"
size="large"
color={theme.color.LIGHT_GRAY}
iconSrc={<CopyIcon />}
disabled={false}
ariaLabel="문제 목록 복사"
onClick={copyProblemInfosMarkdownToClipboard}
/>
<IconButton
type="button"
name="다시 추첨하기!"
Expand Down
42 changes: 42 additions & 0 deletions domains/gacha/getProblemInfosInMarkdownText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ProblemInfo } from '@/types/randomDefense';
import { getProblemInfosInMarkdownText } from './getProblemInfosInMarkdownText';

describe('Test #1 - 마크다운 텍스트 생성 테스트', () => {
test('문제 정보들이 주어지면, 문제 정보르 마크다운으로 나타낸 텍스트를 올바르게 반환하여야 한다.', () => {
const problemInfos: ProblemInfo[] = [
{
problemId: 22289,
title: '큰 수 곱셈 (3)',
tier: 20,
},
{
problemId: 1064,
title: '평행사변형',
tier: 6,
},
{
problemId: 31002,
title: '그래프 변환',
tier: 14,
},
];

const expectedMarkdownText = `
# 추첨 결과 \u{1F3B2}
## 추첨 정보 \u2705
- 추첨 이름: 테스트용 연습
- 문제 수: 3
## 문제 목록 \u{1f4dc}
- 22289번 - 큰 수 곱셈 (3) (https://acmicpc.net/problem/22289)
- 1064번 - 평행사변형 (https://acmicpc.net/problem/1064)
- 31002번 - 그래프 변환 (https://acmicpc.net/problem/31002)
`.trim();

expect(getProblemInfosInMarkdownText('테스트용 연습', problemInfos)).toBe(
expectedMarkdownText,
);
});
});
29 changes: 29 additions & 0 deletions domains/gacha/getProblemInfosInMarkdownText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ProblemInfo } from '@/types/randomDefense';

const diceEmoji = '\u{1F3B2}';
const greenCheckEmoji = '\u2705';
const scrollEmoji = '\u{1f4dc}';

export const getProblemInfosInMarkdownText = (
slotTitle: string,
problemInfos: ProblemInfo[],
) => {
const problemListInMarkdownText = problemInfos
.map(
({ problemId, title }) =>
`- ${problemId}번 - ${title} (https://acmicpc.net/problem/${problemId})`,
)
.join('\n');

return `
# 추첨 결과 ${diceEmoji}
## 추첨 정보 ${greenCheckEmoji}
- 추첨 이름: ${slotTitle}
- 문제 수: ${problemInfos.length}
## 문제 목록 ${scrollEmoji}
${problemListInMarkdownText}
`.trim();
};
24 changes: 23 additions & 1 deletion hooks/gacha/useRandomDefenseGachaModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { FilledSlot } from '@/types/randomDefense';
import type { ProblemInfo } from '@/types/randomDefense';
import type { PreviewCardRanks } from '@/types/gacha';
import { isGachaOptionsResponse } from '@/domains/dataHandlers/validators/gachaOptionsValidator';
import { getProblemInfosInMarkdownText } from '@/domains/gacha/getProblemInfosInMarkdownText';

interface UseRandomDefenseGachaModalParams {
open: boolean;
Expand Down Expand Up @@ -58,6 +59,9 @@ const useRandomDefenseGachaModal = (
);
const [isTierHidden, setIsTierHidden] = useState(false);
const [isAudioMuted, setIsAudioMuted] = useState(true);
const [notificationMessage, setNotificationMessage] = useState('');
const [shouldNotificationFadeOut, setShouldNotificationFadeOut] =
useState(true);
const gachaAudioRef = useRef<HTMLAudioElement>(new Audio(gachaAudio));

const previewCardRanks: PreviewCardRanks =
Expand Down Expand Up @@ -109,6 +113,7 @@ const useRandomDefenseGachaModal = (
const { isTierHidden, isAudioMuted } = gachaOptions;
setIsTierHidden(isTierHidden);
setIsAudioMuted(isAudioMuted);
gachaAudioRef.current.muted = isAudioMuted;
}, []);

const restartGacha = () => {
Expand Down Expand Up @@ -145,6 +150,20 @@ const useRandomDefenseGachaModal = (
gachaAudioRef.current.currentTime = 0;
};

const copyProblemInfosMarkdownToClipboard = () => {
navigator.clipboard.writeText(
getProblemInfosInMarkdownText(slot.title, problemInfos),
);
setNotificationMessage('문제 목록을 클립보드에 복사했어요!');
setShouldNotificationFadeOut(false);
setTimeout(() => setShouldNotificationFadeOut(true));
};

const showResultScreenAndResetNotificationMessage = () => {
setGachaStatus('showingResult');
setNotificationMessage('');
};

useEffect(() => {
restartGacha();
}, [open, slot, problemCount]);
Expand All @@ -170,13 +189,16 @@ const useRandomDefenseGachaModal = (
errorDescriptions,
isTierHidden,
isAudioMuted,
setGachaStatus,
notificationMessage,
shouldNotificationFadeOut,
restartGacha,
toggleIsTierHidden,
toggleIsAudioMuted,
playCardSlideAudio,
playGachaAudio,
stopGachaAudio,
copyProblemInfosMarkdownToClipboard,
showResultScreenAndResetNotificationMessage,
};
};

Expand Down

0 comments on commit 6238512

Please sign in to comment.