Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: push notification 15min Modal and Alert #379

Merged
merged 11 commits into from
Nov 18, 2024
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"editor.formatOnSave": false,
"cSpell.words": [
"Swal",
"Zabo",
"Ziggle"
],
Expand Down
1 change: 1 addition & 0 deletions src/api/notice/notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface Notice {
uuid: string;
};
createdAt: dayjs.Dayjs | string;
publishedAt: dayjs.Dayjs | string;
tags: string[];
views: number;
imageUrls: string[];
Expand Down
5 changes: 5 additions & 0 deletions src/api/notice/send-alarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ziggleApi } from '..';
import type { Notice } from './notice';

export const sendNoticeAlarm = ({ id }: Pick<Notice, 'id'>) =>
ziggleApi.post(`/notice/${id}/alarm`).then((res) => res.data);
enc2586 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use client';

import { useSession } from 'next-auth/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Swal from 'sweetalert2';

import { NoticeDetail } from '@/api/notice/notice';
import { sendNoticeAlarm } from '@/api/notice/send-alarm';
import { PropsWithLng } from '@/app/i18next';
import { useTranslation } from '@/app/i18next/client';

import { getTimeDiff } from './getTimeDiff';

interface SendPushAlarmProps
extends Pick<NoticeDetail, 'id' | 'author' | 'publishedAt'> {}

const SendPushAlarm = ({
id,
author,
publishedAt,
lng,
}: PropsWithLng<SendPushAlarmProps>): JSX.Element | null => {
const { t } = useTranslation(lng);

const [isManuallyAlarmed, setIsManuallyAlarmed] = useState(false);
const [isLoading, setIsLoading] = useState(false);

const handleSendPushNotification = useCallback(async () => {
if (isLoading) return;
const result = await Swal.fire({
text: t('write.alerts.sendPushNotice'),
icon: 'question',
showCancelButton: true,
confirmButtonText: t('alertResponse.confirm'),
cancelButtonText: t('alertResponse.cancel'),
});

if (!result.isConfirmed) return;
setIsLoading(true);

try {
Swal.fire({
text: t('write.alerts.sendingAlarmNotice'),
icon: 'info',
showConfirmButton: false,
allowOutsideClick: false,
});

const newNotice = await sendNoticeAlarm({ id }).catch(() => null);

if (!newNotice) throw new Error('No newNotice returned');

setIsManuallyAlarmed(true);

Swal.fire({
text: t('write.alerts.sendPushNoticeSuccess'),
icon: 'success',
confirmButtonText: t('alertResponse.confirm'),
});
} catch (error) {
Swal.fire({
text: t('write.alerts.sendPushNoticeFail'),
icon: 'error',
confirmButtonText: t('alertResponse.confirm'),
});
} finally {
setIsLoading(false);
}
}, [t, id, isLoading]);
enc2586 marked this conversation as resolved.
Show resolved Hide resolved

const { data: user } = useSession();
const isMyNotice = user?.user.uuid === author.uuid;

enc2586 marked this conversation as resolved.
Show resolved Hide resolved
const MINUTE = 60000;
const TEN_SECONDS = 10000;
const ONE_SECOND = 1000;

const getIntervalDuration = (minutes: number, seconds: number) => {
if (minutes > 1) return MINUTE;
if (seconds > 15) return TEN_SECONDS;
return ONE_SECOND;
};

const [timeRemaining, setTimeRemaining] = useState(getTimeDiff(publishedAt));
useEffect(() => {
let isSubscribed = true;
if (
isManuallyAlarmed ||
timeRemaining.minutes < 0 ||
timeRemaining.seconds < 0
) {
return;
}

const intervalDuration = getIntervalDuration(
timeRemaining.minutes,
timeRemaining.seconds
);

const interval = setInterval(() => {
if (isSubscribed) {
const newTimeRemaining = getTimeDiff(publishedAt);
if (
newTimeRemaining.minutes !== timeRemaining.minutes ||
newTimeRemaining.seconds !== timeRemaining.seconds
) {
setTimeRemaining(newTimeRemaining);
}
}
}, intervalDuration);

return () => {
isSubscribed = false;
clearInterval(interval);
};
}, [timeRemaining, publishedAt, isManuallyAlarmed]);

const isEditable = timeRemaining.minutes >= 0 && timeRemaining.seconds >= 0;

const showComponent = useMemo(
() => isMyNotice && isEditable && !isManuallyAlarmed,
[isMyNotice, isEditable, isManuallyAlarmed],
);

return (
<div
className={`transform transition-all duration-1000 ease-in-out ${
showComponent ? 'max-h-screen' : 'max-h-0 overflow-hidden'
}`}
>
<div
className={`inline-flex w-full items-start justify-start gap-1.5 rounded-[15px] bg-[#fff4f0] px-5 py-[15px] font-normal text-primary`}
>
<span>{t('zabo.sentPushNotificationAlert.title')} </span>
<span
className="cursor-pointer font-bold underline"
onClick={handleSendPushNotification}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSendPushNotification();
}
}}
>
{t('zabo.sentPushNotificationAlert.action')}
</span>
</div>
</div>
);
};

export default SendPushAlarm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import dayjs, { Dayjs } from 'dayjs';

export const isServer = typeof window === 'undefined';
enc2586 marked this conversation as resolved.
Show resolved Hide resolved

const CLIENT_SERVER_TIME_OFFSET_SECONDS = 10
export const getTimeDiff = (createdAt: Dayjs | string) => {
const currentTime = dayjs();
const diffInSeconds = dayjs(createdAt)
.subtract(CLIENT_SERVER_TIME_OFFSET_SECONDS, 'second')
.diff(currentTime, 'second');
const minutes = Math.floor(diffInSeconds / 60);
const seconds = diffInSeconds % 60;
return { minutes, seconds };
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AdditionalNotices from './AdditionalNotices';
import Content from './Content';
import ImageStack from './ImageStack';
import NoticeInfo from './NoticeInfo';
import SendPushAlarm from './SendPushNotificationAlert';

export const generateMetadata = async (
{
Expand Down Expand Up @@ -71,6 +72,8 @@ const DetailedNoticePage = async ({
</div>

<div className="flex flex-col gap-[18px] md:w-[60%]">
<SendPushAlarm {...notice} lng={lng} />

<NoticeInfo
{...notice}
currentDeadline={notice.currentDeadline ?? null}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
'use client';

import Link from 'next/link';
import React from 'react';

import CSLink from '@/app/components/shared/CSLink/CSLink';
import { PropsWithLng } from '@/app/i18next';
import { createTranslation, PropsWithLng } from '@/app/i18next';
import { useTranslation } from '@/app/i18next/client';
import BellIcon from '@/assets/icons/bell.svg';
import PencilIcon from '@/assets/icons/edit-pencil.svg';
Expand Down Expand Up @@ -42,8 +40,8 @@ const MypageButton = ({ icon, buttonText, align }: MypageButtonType) => {
);
};

const MypageButtons = ({ lng }: PropsWithLng) => {
const { t } = useTranslation(lng);
const MypageButtons = async ({ lng }: PropsWithLng) => {
const { t } = await createTranslation(lng);

const ICON_CLASSNAME = 'w-10 stroke-text dark:stroke-dark_white';

Expand Down
6 changes: 6 additions & 0 deletions src/app/[lng]/write/NoticeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ const NoticeEditor = ({
const handleSubmit = async () => {
if (isLoading) return;

await Swal.fire({
text: t('write.alerts.pushWillDelayedNotice'),
icon: 'info',
confirmButtonText: t('alertResponse.confirm'),
});

setIsLoading(true);
const noticeId = await handleNoticeSubmit({
title: state.korean.title,
Expand Down
10 changes: 5 additions & 5 deletions src/app/[lng]/write/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import 'react-calendar/dist/Calendar.css';
import 'react-clock/dist/Clock.css';
import 'react-datetime-picker/dist/DateTimePicker.css';

import { notFound, redirect } from 'next/navigation';

import { auth } from '@/api/auth/auth';
Expand All @@ -6,10 +10,6 @@ import { createTranslation, PropsWithLng } from '@/app/i18next';

import NoticeEditor from './NoticeEditor';

import 'react-calendar/dist/Calendar.css';
import 'react-clock/dist/Clock.css';
import 'react-datetime-picker/dist/DateTimePicker.css';

const WritePage = async ({
params: { lng },
searchParams,
Expand All @@ -32,7 +32,7 @@ const WritePage = async ({
searchParams?.noticeId && notice?.langs.includes('en')
? await getNotice(Number.parseInt(searchParams.noticeId), 'en')
: null;
if ((searchParams?.noticeId) && !notice) notFound();
if (searchParams?.noticeId && !notice) notFound();

const isAuthor = userData.user.uuid === notice?.author.uuid;
if (notice && !isAuthor) redirect(`/${lng}/notice/${notice.id}`);
Expand Down
12 changes: 11 additions & 1 deletion src/app/i18next/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@
"writeTitle": "Write a title of English notice here"
},

"sentPushNotificationAlert": {
"title": "You can't edit once you send a notification.",
"action": "Send Push Notification"
},

"authorActions": {
"edit": "Edit / Add English Version",
"remove": "Remove",
Expand Down Expand Up @@ -319,7 +324,12 @@
"attachAdditionalNoticeFail": "Failed to post additional notice. Please try posting it again.",
"copyAdditionalNotice": "Copy additional notice",
"attachInternationalAdditionalNoticeFail": "Failed to post English additional notice. Please try posting it again.",
"copyInternationalAdditionalNotice": "Copy En. add. notice"
"copyInternationalAdditionalNotice": "Copy En. add. notice",
"pushWillDelayedNotice": "Users will receive a push notification after 15 minutes. If it's urgent, you can use Send Notification button in your notice page.",
"sendPushNotice": "Notifications will be sent to all users. Are you sure you want to send them now?",
"sendingAlarmNotice":"Sending notifications...",
"sendPushNoticeFail": "Failed to send push notification",
"sendPushNoticeSuccess": "Push notification sent successfully"
}
},
"group": {
Expand Down
12 changes: 11 additions & 1 deletion src/app/i18next/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@
"writeTitle": "영어 공지 제목을 입력하세요"
},

"sentPushNotificationAlert": {
"title": "알림을 보내면 수정이 불가능합니다.",
"action": "알림 보내기"
},

"authorActions": {
"edit": "수정 / 영어공지 작성",
"remove": "삭제",
Expand Down Expand Up @@ -305,7 +310,12 @@
"attachAdditionalNoticeFail": "추가 공지 작성에 실패했습니다",
"copyAdditionalNotice": "추가 공지 복사",
"attachInternationalAdditionalNoticeFail": "영어 추가 공지 작성에 실패했습니다",
"copyInternationalAdditionalNotice": "영어 추가 공지 복사"
"copyInternationalAdditionalNotice": "영어 추가 공지 복사",
"pushWillDelayedNotice": "15분 뒤에 사용자들에게 푸시알람이 전송됩니다. 긴급한 경우 내 공지 > 알림 보내기 기능을 사용하세요.",
"sendPushNotice": "모든 사용자들에게 알림이 전송됩니다. 정말 지금 알림을 보낼까요?",
"sendingAlarmNotice":"알림 보내는 중...",
"sendPushNoticeFail": "알림 전송에 실패했습니다",
"sendPushNoticeSuccess": "알림을 성공적으로 보냈습니다"
}
},
"group": {
Expand Down
Loading