diff --git a/.vscode/settings.json b/.vscode/settings.json index 7962fd64..f3bfe664 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ }, "editor.formatOnSave": false, "cSpell.words": [ + "Swal", "Zabo", "Ziggle" ], diff --git a/src/api/notice/notice.ts b/src/api/notice/notice.ts index 57076af4..63c45c45 100644 --- a/src/api/notice/notice.ts +++ b/src/api/notice/notice.ts @@ -44,6 +44,7 @@ export interface Notice { uuid: string; }; createdAt: dayjs.Dayjs | string; + publishedAt: dayjs.Dayjs | string; tags: string[]; views: number; imageUrls: string[]; diff --git a/src/api/notice/send-alarm.ts b/src/api/notice/send-alarm.ts new file mode 100644 index 00000000..43a0a785 --- /dev/null +++ b/src/api/notice/send-alarm.ts @@ -0,0 +1,5 @@ +import { ziggleApi } from '..'; +import type { Notice } from './notice'; + +export const sendNoticeAlarm = ({ id }: Pick) => + ziggleApi.post(`/notice/${id}/alarm`).then((res) => res.data); diff --git a/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/SendPushNotificationAlert.tsx b/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/SendPushNotificationAlert.tsx new file mode 100644 index 00000000..1910e45c --- /dev/null +++ b/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/SendPushNotificationAlert.tsx @@ -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 {} + +const SendPushAlarm = ({ + id, + author, + publishedAt, + lng, +}: PropsWithLng): 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]); + + const { data: user } = useSession(); + const isMyNotice = user?.user.uuid === author.uuid; + + 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 ( +
+
+ {t('zabo.sentPushNotificationAlert.title')} + { + if (e.key === 'Enter' || e.key === ' ') { + handleSendPushNotification(); + } + }} + > + {t('zabo.sentPushNotificationAlert.action')} + +
+
+ ); +}; + +export default SendPushAlarm; diff --git a/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/getTimeDiff.ts b/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/getTimeDiff.ts new file mode 100644 index 00000000..7002359b --- /dev/null +++ b/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/getTimeDiff.ts @@ -0,0 +1,14 @@ +import dayjs, { Dayjs } from 'dayjs'; + +export const isServer = typeof window === 'undefined'; + +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 }; +}; diff --git a/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/page.tsx b/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/page.tsx index f6002230..4300f78c 100644 --- a/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/page.tsx +++ b/src/app/[lng]/(with-page-layout)/(with-sidebar-layout)/notice/[id]/page.tsx @@ -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 ( { @@ -71,6 +72,8 @@ const DetailedNoticePage = async ({
+ + { ); }; -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'; diff --git a/src/app/[lng]/write/NoticeEditor.tsx b/src/app/[lng]/write/NoticeEditor.tsx index 1811bcc8..5970d33c 100644 --- a/src/app/[lng]/write/NoticeEditor.tsx +++ b/src/app/[lng]/write/NoticeEditor.tsx @@ -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, diff --git a/src/app/[lng]/write/page.tsx b/src/app/[lng]/write/page.tsx index 13c4d1fb..0a9c4f7c 100644 --- a/src/app/[lng]/write/page.tsx +++ b/src/app/[lng]/write/page.tsx @@ -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'; @@ -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, @@ -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}`); diff --git a/src/app/i18next/locales/en/translation.json b/src/app/i18next/locales/en/translation.json index 4222e3a2..2443b601 100644 --- a/src/app/i18next/locales/en/translation.json +++ b/src/app/i18next/locales/en/translation.json @@ -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", @@ -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": { diff --git a/src/app/i18next/locales/ko/translation.json b/src/app/i18next/locales/ko/translation.json index eddc5206..bb145384 100644 --- a/src/app/i18next/locales/ko/translation.json +++ b/src/app/i18next/locales/ko/translation.json @@ -126,6 +126,11 @@ "writeTitle": "영어 공지 제목을 입력하세요" }, + "sentPushNotificationAlert": { + "title": "알림을 보내면 수정이 불가능합니다.", + "action": "알림 보내기" + }, + "authorActions": { "edit": "수정 / 영어공지 작성", "remove": "삭제", @@ -305,7 +310,12 @@ "attachAdditionalNoticeFail": "추가 공지 작성에 실패했습니다", "copyAdditionalNotice": "추가 공지 복사", "attachInternationalAdditionalNoticeFail": "영어 추가 공지 작성에 실패했습니다", - "copyInternationalAdditionalNotice": "영어 추가 공지 복사" + "copyInternationalAdditionalNotice": "영어 추가 공지 복사", + "pushWillDelayedNotice": "15분 뒤에 사용자들에게 푸시알람이 전송됩니다. 긴급한 경우 내 공지 > 알림 보내기 기능을 사용하세요.", + "sendPushNotice": "모든 사용자들에게 알림이 전송됩니다. 정말 지금 알림을 보낼까요?", + "sendingAlarmNotice":"알림 보내는 중...", + "sendPushNoticeFail": "알림 전송에 실패했습니다", + "sendPushNoticeSuccess": "알림을 성공적으로 보냈습니다" } }, "group": {