@ihabdevteam/core 0.322.0 → 0.323.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +517 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +54 -16
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +6 -13
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
- package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.js +13 -13
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
- package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.js +4 -4
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.js +2 -2
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +38 -48
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import './Loader.css';
|
|
5
5
|
/** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
|
|
6
|
-
const
|
|
6
|
+
const srOnly = {
|
|
7
7
|
position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
|
|
8
8
|
clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
|
|
9
9
|
};
|
|
@@ -15,10 +15,10 @@ export default function Loader(props) {
|
|
|
15
15
|
const name = label || t('loader.loading', 'Loading');
|
|
16
16
|
if (variant === 'bar') {
|
|
17
17
|
/* 값을 모르면 0% 로 읽히고(빈 막대) 멈춘 채 보였다 — 값 없이 진행 중으로 둔다 */
|
|
18
|
-
const
|
|
19
|
-
const pct =
|
|
20
|
-
return (_jsx("div", { className: `loader-bar${
|
|
18
|
+
const unknown = progress == null || !Number.isFinite(Number(progress));
|
|
19
|
+
const pct = unknown ? 0 : Math.max(0, Math.min(100, Number(progress)));
|
|
20
|
+
return (_jsx("div", { className: `loader-bar${unknown ? ' loader-bar--indeterminate' : ''}`, role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": unknown ? undefined : pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: unknown ? undefined : { width: `${pct}%` } }) }));
|
|
21
21
|
}
|
|
22
22
|
const dim = typeof size === 'number' ? `${size}px` : size;
|
|
23
|
-
return (_jsxs("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: [_jsx("span", { style:
|
|
23
|
+
return (_jsxs("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: [_jsx("span", { style: srOnly, children: name }), _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] })] }));
|
|
24
24
|
}
|
|
@@ -19,12 +19,12 @@ export default function NoticeBox({ title = '', lines = [], notices = null, chil
|
|
|
19
19
|
if (dismissed)
|
|
20
20
|
return null;
|
|
21
21
|
const baseClass = `notice-box notice-box--${variant} body-2 ${className}`.trim();
|
|
22
|
-
const
|
|
22
|
+
const closeButton = dismissible && (_jsx("button", {
|
|
23
23
|
// type="button" — 없으면 폼 안에서 닫기를 누르는 순간 폼이 제출된다
|
|
24
24
|
type: "button", className: "notice-box__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }));
|
|
25
25
|
if (notices && Array.isArray(notices) && notices.length) {
|
|
26
|
-
return (_jsxs("div", { className: baseClass, role: "note", children: [notices.map((n, idx) => (_jsxs("div", { className: "notice-box__inner", children: [n.title ? _jsx("span", { className: "notice-box__title body-3", children: n.title }) : null, _jsx("div", { className: "notice-box__body", children: n.lines && n.lines.length ? (_jsx("div", { children: n.lines.map((l, i) => (_jsx("span", { className: "notice-box__line body-3", children: l }, i))) })) : (n.children ?? null) })] }, idx))),
|
|
26
|
+
return (_jsxs("div", { className: baseClass, role: "note", children: [notices.map((n, idx) => (_jsxs("div", { className: "notice-box__inner", children: [n.title ? _jsx("span", { className: "notice-box__title body-3", children: n.title }) : null, _jsx("div", { className: "notice-box__body", children: n.lines && n.lines.length ? (_jsx("div", { children: n.lines.map((l, i) => (_jsx("span", { className: "notice-box__line body-3", children: l }, i))) })) : (n.children ?? null) })] }, idx))), closeButton] }));
|
|
27
27
|
}
|
|
28
28
|
const content = (title || lines.length) ? (_jsxs("div", { className: "notice-box__inner", children: [title ? _jsx("span", { className: "notice-box__title body-1", children: title }) : null, _jsx("div", { className: "notice-box__body", children: lines.length ? (_jsx("ul", { children: lines.map((l, i) => (_jsx("li", { className: "notice-box__line", children: l }, i))) })) : children })] })) : (_jsx("div", { className: "notice-box__inner", children: children }));
|
|
29
|
-
return (_jsxs("div", { ref: rootRef, className: baseClass, role: "note", children: [content,
|
|
29
|
+
return (_jsxs("div", { ref: rootRef, className: baseClass, role: "note", children: [content, closeButton] }));
|
|
30
30
|
}
|
|
@@ -10,11 +10,11 @@ export default function PageTitle({ title, sub, rightAction, titleAction, badge,
|
|
|
10
10
|
const { t } = useTranslation();
|
|
11
11
|
// 페이지 제목이 곧 문서 제목이 되도록 맞춘다(WCAG 2.4.2).
|
|
12
12
|
useDocumentTitle(typeof title === 'string' ? title : (documentTitle ?? null));
|
|
13
|
-
const
|
|
13
|
+
const backText = backLabel ?? t('common.back', '뒤로');
|
|
14
14
|
return (_jsxs("div", { className: wrap ? 'page-title page-title--wrap' : 'page-title', children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [onBack && (
|
|
15
15
|
/* 글자 없이 화살표만 둔다 — 제목 옆에서 '뒤로' 라고 적으면 제목과 경쟁한다.
|
|
16
16
|
그만큼 과녁을 크게(48) 잡는다. 이름은 낭독기용으로 따로 준다. */
|
|
17
17
|
_jsx(Button, { htmlType: "button", className: "page-title__back", size: 48, type: "dot", icon: "chevron-left", iconSize: "large", useBorder: false,
|
|
18
18
|
/* 역할 이름을 쓴다 — `--color-black` 은 다크에서 이름과 실제 색이 뒤집힌다. */
|
|
19
|
-
iconColor: "var(--color-text-primary)", ariaLabel:
|
|
19
|
+
iconColor: "var(--color-text-primary)", ariaLabel: backText, showTooltipInfo: backText, onClick: onBack })), _jsx("h1", { className: "page-title__title h1", children: title }), badge && _jsx("div", { className: "page-title__badge", children: _jsx(Badge, { label: badge, size: "small", variant: badgeVariant, fill: "outline" }) }), titleAction ? _jsx("div", { className: "page-title__title-action", children: titleAction }) : null] }), sub && _jsx("p", { className: "page-title__sub body-2", children: sub })] }), rightAction ? _jsx("div", { className: "page-title__action", children: rightAction }) : null] }));
|
|
20
20
|
}
|
|
@@ -16,8 +16,8 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
|
|
|
16
16
|
const resolvedNext = nextLabel ?? t('common.nextPage', '다음 페이지');
|
|
17
17
|
/* 범위 밖 쪽(page > totalPages — 결과가 줄어든 직후)에서도 '이전' 이 범위 안으로 돌아오게 먼저 조인다.
|
|
18
18
|
전에는 page-1 이 여전히 totalPages 보다 커 눌러도 범위 밖에 머물렀다. */
|
|
19
|
-
const
|
|
20
|
-
const handlePrev = () => onChange(Math.max(1, Math.min(
|
|
21
|
-
const handleNext = () => onChange(Math.min(
|
|
22
|
-
return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", "aria-live": "polite", "aria-atomic": "true", children: [Math.min(Math.max(1, page),
|
|
19
|
+
const lastPage = Math.max(1, totalPages);
|
|
20
|
+
const handlePrev = () => onChange(Math.max(1, Math.min(lastPage, page - 1)));
|
|
21
|
+
const handleNext = () => onChange(Math.min(lastPage, page + 1));
|
|
22
|
+
return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", "aria-live": "polite", "aria-atomic": "true", children: [Math.min(Math.max(1, page), lastPage), " / ", lastPage] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
|
|
23
23
|
}
|
|
@@ -16,8 +16,8 @@ export default function Panel({ title, titleLevel = 2, icon, disc, actions, size
|
|
|
16
16
|
style.maxWidth = typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
|
|
17
17
|
if (labelWidth !== undefined)
|
|
18
18
|
style['--panel-label-width'] = typeof labelWidth === 'number' ? `${labelWidth}px` : labelWidth;
|
|
19
|
-
const
|
|
20
|
-
return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(!!title || disc != null) && (_jsxs("div", { className: cx('panel__header', disc != null ? 'panel__header--with-disc' : ''), children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "panel__icon" })), _jsxs("div", { className: "panel__title-group", children: [!!title && (_jsx(
|
|
19
|
+
const TitleTag = titleLevel ? `h${titleLevel}` : 'div';
|
|
20
|
+
return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(!!title || disc != null) && (_jsxs("div", { className: cx('panel__header', disc != null ? 'panel__header--with-disc' : ''), children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "panel__icon" })), _jsxs("div", { className: "panel__title-group", children: [!!title && (_jsx(TitleTag, { className: `panel__title ${disc != null ? 'body-3b' : 'h5b'}`, children: title })), disc != null && _jsx("p", { className: "panel__disc caption-1", children: disc })] }), actions && _jsx("div", { className: "panel__actions", children: actions })] })), loading ? (_jsx(PanelRow, { variant: "loading" })
|
|
21
21
|
/* 비었는지는 React 가 그리는 것으로 센다 — `{조건 && 행들}` 이 false 면 children 이 false 라 빈 상태 문구가 안 떴다 */
|
|
22
22
|
) : empty != null && Children.toArray(children).length === 0 ? (_jsx(PanelRow, { variant: "empty", children: empty })) : scrollable ? (_jsx("div", { className: cx('panel__body--scroll', scrollbarGutter === 'stable' && 'panel__body--gutter-stable'), children: children })) : children] }));
|
|
23
23
|
}
|
|
@@ -8,15 +8,8 @@ import SegmentedControl from './SegmentedControl';
|
|
|
8
8
|
import Dropdown from './Dropdown';
|
|
9
9
|
import Button from './Button';
|
|
10
10
|
import Badge from './Badge';
|
|
11
|
-
import { deviceKindVariant } from '../utils/deviceKind';
|
|
11
|
+
import { deviceKindVariant, deviceKindLabel } from '../utils/deviceKind';
|
|
12
12
|
import { formatWornDate } from '../utils/wornDate';
|
|
13
|
-
/* 기기 종류 배지 이름은 지금 언어로 — 값은 저장된 한국어(보청기·인공와우)라 그대로 보이면 영어 화면에도 한국어였다.
|
|
14
|
-
색(deviceKindVariant)은 저장값으로 가른다. 모르는 값은 그대로 */
|
|
15
|
-
const 기기종류열쇠 = { '보청기': 'hearingAid', '인공와우': 'cochlearImplant' };
|
|
16
|
-
function 기기종류이름(device, t) {
|
|
17
|
-
const k = 기기종류열쇠[device];
|
|
18
|
-
return k ? t(`device.type.${k}`, { defaultValue: device }) : device;
|
|
19
|
-
}
|
|
20
13
|
import TextField from './TextField';
|
|
21
14
|
import Loader from './Loader';
|
|
22
15
|
import './Panel.css';
|
|
@@ -145,10 +138,10 @@ export function PanelRow(props) {
|
|
|
145
138
|
* 제목으로 이미 쓴 값과 빈 값, 그리고 서로 같은 값을 걷어 낸다. 걷어 내지 않으면
|
|
146
139
|
* 브랜드도 형태도 '기타' 인 기기가 **'기타 · 기타'** 로 떴다(같은 말이 두 번).
|
|
147
140
|
*/
|
|
148
|
-
function
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
return [...new Set(
|
|
141
|
+
function metaOf(d) {
|
|
142
|
+
const title = d.productName || d.brand;
|
|
143
|
+
const parts = [d.brand, d.fitType].filter((v) => !!v && v !== title);
|
|
144
|
+
return [...new Set(parts)].join(' · ');
|
|
152
145
|
}
|
|
153
146
|
export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
154
147
|
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
@@ -162,6 +155,6 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
|
162
155
|
(잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
|
|
163
156
|
_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
|
|
164
157
|
// 좌·우 두 단추가 같은 이름이라 어느 쪽 기기를 내리는지 안 들렸다 — 이름에만 쪽을 붙인다(툴팁은 그대로)
|
|
165
|
-
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label:
|
|
158
|
+
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: deviceKindLabel(d.device, t) }), metaOf(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: metaOf(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) && (_jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) }) }))] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
166
159
|
}) }));
|
|
167
160
|
}
|
|
@@ -71,7 +71,7 @@ const MAX_TILT = 10;
|
|
|
71
71
|
*
|
|
72
72
|
* `formatRemaining` 과 **같은 사다리**를 탄다. 한쪽만 고치면 또 갈리므로 바로 아래 붙여 둔다.
|
|
73
73
|
*/
|
|
74
|
-
function
|
|
74
|
+
function toDisplayUnit(seconds) {
|
|
75
75
|
if (seconds <= 0)
|
|
76
76
|
return 0;
|
|
77
77
|
if (seconds >= 86400)
|
|
@@ -169,10 +169,10 @@ export default function PassCard({ ariaLabel, product, showPrice = false, plan,
|
|
|
169
169
|
const end = new Date(endDate).getTime();
|
|
170
170
|
if (Number.isNaN(end))
|
|
171
171
|
return null;
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const days =
|
|
175
|
-
? Math.round((Date.UTC(
|
|
172
|
+
const endMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(endDate));
|
|
173
|
+
const todayMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(getTodayKst());
|
|
174
|
+
const days = endMatch && todayMatch
|
|
175
|
+
? Math.round((Date.UTC(+endMatch[1], +endMatch[2] - 1, +endMatch[3]) - Date.UTC(+todayMatch[1], +todayMatch[2] - 1, +todayMatch[3])) / 86_400_000)
|
|
176
176
|
: Math.round((end - Date.now()) / 86_400_000);
|
|
177
177
|
/* endInclusive — 종료일 당일까지 쓰는 이용권이면 그 날도 남은 날이다(마지막 날이 0초·0% 로 보이지 않게) */
|
|
178
178
|
remaining = Math.max(0, endInclusive ? days + 1 : days) * 86400;
|
|
@@ -192,8 +192,8 @@ export default function PassCard({ ariaLabel, product, showPrice = false, plan,
|
|
|
192
192
|
/* **옆에 적히는 글과 같은 값을 센다**(말귀 요청, 2026-10-02) — 자세한 까닭은
|
|
193
193
|
`적히는눈금으로` 머리글. 날짜(`endDate`)로 셈한 길은 이미 날 단위 정수라 이 걸음이
|
|
194
194
|
아무것도 바꾸지 않는다. 벌어지던 것은 초를 그대로 받는 `remainingSeconds` 쪽이다. */
|
|
195
|
-
const
|
|
196
|
-
const percent = Math.max(0, Math.min(100, Math.floor((Math.min(
|
|
195
|
+
const displayRemaining = toDisplayUnit(remaining);
|
|
196
|
+
const percent = Math.max(0, Math.min(100, Math.floor((Math.min(displayRemaining, totalSeconds) / totalSeconds) * 100)));
|
|
197
197
|
if (state === 'pending' && percent >= 100)
|
|
198
198
|
return null;
|
|
199
199
|
/* **만료는 '0초 남음' 이 아니다**(말귀 요청 ㊾). "0초 남았다" 는 **곧 끝난다**는 말로
|
|
@@ -301,8 +301,8 @@ export default function PassCard({ ariaLabel, product, showPrice = false, plan,
|
|
|
301
301
|
const handleCloseMenu = useCallback(() => setMenuAnchor(null), []);
|
|
302
302
|
const planDisplay = useMemo(() => {
|
|
303
303
|
/* 폴백 플랜('이용권'·영어 'Plan')은 큰 글씨에서 뺀다 — 아래 꼬리(planSuffix)에 이미 적힌다 */
|
|
304
|
-
const
|
|
305
|
-
const text = rPlan.replace('이용권', '').replace(
|
|
304
|
+
const planSuffix = t('passCard.planSuffix', '이용권');
|
|
305
|
+
const text = rPlan.replace('이용권', '').replace(planSuffix === '이용권' ? '' : planSuffix, '').trim();
|
|
306
306
|
const match = text.match(/^(.*?)(일|년)$/);
|
|
307
307
|
return match
|
|
308
308
|
? _jsxs(_Fragment, { children: [_jsx("span", { className: "h0 pass-card__plan-number", children: match[1] }), _jsx("span", { className: "h5b pass-card__plan-unit", children: match[2] === '년' ? t('passCard.unitYear', '년') : t('passCard.unitDay', '일') })] })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { toWonSymbol } from '../../utils/format';
|
|
2
2
|
import i18n from '../../i18n';
|
|
3
3
|
/** 기간을 못 뽑았을 때의 플랜 글자 — 지금 언어의 `passCard.planSuffix`(ko '이용권'). 전에는 한국어가 박혀 영어 화면에도 떴다. */
|
|
4
|
-
const
|
|
4
|
+
const planFallback = () => i18n.t('passCard.planSuffix', '이용권');
|
|
5
5
|
/** 숫자(원) 또는 문자열 가격을 '₩12,345' 형태로 통일. 숫자만 든 문자열('12000'·'12,000')도 수로 보고 ₩ 를 붙인다
|
|
6
6
|
* — 전에는 문자열이면 '원' 이 붙은 것만 바꿔, DB 에서 글자로 온 '12000' 이 기호·자릿수 없이 떴다. */
|
|
7
7
|
function formatPrice(v) {
|
|
@@ -39,7 +39,7 @@ function derivePlan(p) {
|
|
|
39
39
|
const en = name.match(/(\d+)\s*-?\s*(day|year)s?\b/i);
|
|
40
40
|
if (en)
|
|
41
41
|
return en[2].toLowerCase() === 'year' ? `${en[1]}년` : `${en[1]}일`;
|
|
42
|
-
return name ||
|
|
42
|
+
return name || planFallback();
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
45
|
* 상품코드 접미사(_30d, _7d, _1y 등)에서 기간(일)을 도출 — derivePlan과 같은
|
|
@@ -38,8 +38,8 @@ function renderPriceParts(raw) {
|
|
|
38
38
|
/** @public */
|
|
39
39
|
export default function PaymentPlan(props) {
|
|
40
40
|
const { t } = useTranslation();
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const planFallback = t('payment.plan.tierFallback', { defaultValue: '이용권' });
|
|
42
|
+
const openPromoLabel = t('payment.plan.openPromo', { defaultValue: '오픈 특가' });
|
|
43
43
|
const { plan, onSelect, period = "monthly", ownedCodes } = props || {};
|
|
44
44
|
const owned = (code) => !!code && !!ownedCodes?.includes(code);
|
|
45
45
|
// 이 플랜의 오픈특가 tier를 이미 보유(구매)했는지 — 그러면 기본 tier를 카드로 승격
|
|
@@ -85,14 +85,14 @@ export default function PaymentPlan(props) {
|
|
|
85
85
|
/* 라벨이 없을 때의 대체말도 **화면에 뜨는 글**이다 — 사전에서 가져온다.
|
|
86
86
|
아래 정규식의 '오픈 특가' 는 다르다. 그건 앱이 넘겨준 **데이터를
|
|
87
87
|
읽는** 것이라 한국어 그대로여야 한다. */
|
|
88
|
-
const rawLabel = tier.label ||
|
|
88
|
+
const rawLabel = tier.label || planFallback;
|
|
89
89
|
// 라벨의 '오픈 특가'는 계정당 1회 제한의 표시다 — 숨김·소진 판정에만 쓴다.
|
|
90
90
|
const isOnceOnly = isOnceOnlyTier(tier);
|
|
91
91
|
// 배지 문구는 별도 — 같은 프로모션이되 1회 제한은 없는 tier(1년권)도 함께 얹는다.
|
|
92
92
|
const showOpenPromo = isOnceOnly || tier.openPromo === true;
|
|
93
|
-
const planText = rawLabel.replace(/^오픈\s*특가\s*/, '') ||
|
|
93
|
+
const planText = rawLabel.replace(/^오픈\s*특가\s*/, '') || planFallback;
|
|
94
94
|
const curPrice = tier.prices ? tier.prices[period] : tier.price;
|
|
95
|
-
const discount = tier.badge ? (showOpenPromo ? `${
|
|
95
|
+
const discount = tier.badge ? (showOpenPromo ? `${openPromoLabel}\n${tier.badge}` : tier.badge) : undefined;
|
|
96
96
|
// 이미 구매한 오픈특가(계정당 1회)는 결제 화면에서 숨긴다.
|
|
97
97
|
if (isOnceOnly && owned(tier.code))
|
|
98
98
|
return null;
|
|
@@ -16,16 +16,16 @@ export default function ConfirmPopup(props) {
|
|
|
16
16
|
const { t } = useTranslation();
|
|
17
17
|
/* 열릴 때 포커스 자리 — Popup 이 먼저 첫 포커스 자리(X)로 옮긴 뒤(자식 효과가 먼저 돈다) 여기서 고른 단추로 옮긴다.
|
|
18
18
|
단추 차례는 [곁 단추?, 취소?, 확인] 이다. */
|
|
19
|
-
const
|
|
19
|
+
const bodyRef = useRef(null);
|
|
20
20
|
useEffect(() => {
|
|
21
21
|
if (inline || initialFocus === 'close')
|
|
22
22
|
return;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
: hideCancel ? undefined :
|
|
27
|
-
if (
|
|
28
|
-
|
|
23
|
+
const panel = bodyRef.current?.closest('.popup-panel');
|
|
24
|
+
const buttons = Array.from(panel?.querySelectorAll('.popup-footer--buttons button') ?? []);
|
|
25
|
+
const picked = initialFocus === 'confirm' ? buttons[buttons.length - 1]
|
|
26
|
+
: hideCancel ? undefined : buttons[extraAction ? 1 : 0];
|
|
27
|
+
if (picked && !picked.disabled)
|
|
28
|
+
picked.focus();
|
|
29
29
|
// 열릴 때 한 번만
|
|
30
30
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
31
|
}, []);
|
|
@@ -39,19 +39,19 @@ export default function ConfirmPopup(props) {
|
|
|
39
39
|
단추가 꺼지며 body 로 떨어진 포커스를 누른 단추로 되돌린다. */
|
|
40
40
|
/* closeOnConfirm=false 로 두고 콜백이 넘겨받은 close() 를 **스스로** 부르면(문서가 권하는 "성공했을 때만 닫기") 그것도 닫힘으로
|
|
41
41
|
센다 — 전에는 closeOnConfirm(false) 만 돌려 닫히는 260ms 동안 잠금이 풀려, 한 번 더 누르면 확인이 두 번 나갔다. */
|
|
42
|
-
const
|
|
43
|
-
let
|
|
44
|
-
return { close: () => {
|
|
42
|
+
const makeClose = (closeWithAnimation) => {
|
|
43
|
+
let closed = false;
|
|
44
|
+
return { close: () => { closed = true; closeWithAnimation(); }, didClose: () => closed };
|
|
45
45
|
};
|
|
46
|
-
const
|
|
46
|
+
const once = async (task) => {
|
|
47
47
|
if (busyRef.current)
|
|
48
48
|
return;
|
|
49
49
|
busyRef.current = true;
|
|
50
|
-
const
|
|
50
|
+
const pressedAt = typeof document !== 'undefined' ? document.activeElement : null;
|
|
51
51
|
setBusy(true);
|
|
52
|
-
let
|
|
52
|
+
let closing = false;
|
|
53
53
|
try {
|
|
54
|
-
|
|
54
|
+
closing = await task();
|
|
55
55
|
}
|
|
56
56
|
catch (e) {
|
|
57
57
|
/* 거절은 창을 남기고 잠금을 푸는 것으로 끝낸다(계약) — 받아 주지 않으면 처리되지 않은 거절로 새어 Next 개발 오버레이가 떴다 */
|
|
@@ -59,13 +59,13 @@ export default function ConfirmPopup(props) {
|
|
|
59
59
|
console.error(e);
|
|
60
60
|
}
|
|
61
61
|
finally {
|
|
62
|
-
if (
|
|
62
|
+
if (!closing) {
|
|
63
63
|
busyRef.current = false;
|
|
64
64
|
setBusy(false);
|
|
65
65
|
requestAnimationFrame(() => {
|
|
66
|
-
const
|
|
67
|
-
if ((
|
|
68
|
-
|
|
66
|
+
const active = document.activeElement;
|
|
67
|
+
if ((!active || active === document.body) && pressedAt?.isConnected)
|
|
68
|
+
pressedAt.focus();
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -85,13 +85,13 @@ export default function ConfirmPopup(props) {
|
|
|
85
85
|
// 없는 일(삭제·연결 해제)이라, 두 번 눌려 두 번 나가면 첫 번째만 뜻이 있고
|
|
86
86
|
// 두 번째는 엉뚱한 실패로 돌아온다.
|
|
87
87
|
onClick: async ({ closeWithAnimation }) => {
|
|
88
|
-
await
|
|
89
|
-
const
|
|
90
|
-
await extraAction.onClick(
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
88
|
+
await once(async () => {
|
|
89
|
+
const close = makeClose(closeWithAnimation);
|
|
90
|
+
await extraAction.onClick(close.close);
|
|
91
|
+
const closeOnClick = extraAction.closeOnClick ?? true;
|
|
92
|
+
if (closeOnClick)
|
|
93
93
|
closeWithAnimation();
|
|
94
|
-
return
|
|
94
|
+
return closeOnClick || close.didClose();
|
|
95
95
|
});
|
|
96
96
|
},
|
|
97
97
|
}] : []),
|
|
@@ -106,14 +106,14 @@ export default function ConfirmPopup(props) {
|
|
|
106
106
|
variant: confirmVariant,
|
|
107
107
|
disabled: confirmDisabled || busy,
|
|
108
108
|
onClick: async ({ closeWithAnimation }) => {
|
|
109
|
-
await
|
|
110
|
-
const
|
|
111
|
-
await onConfirm(
|
|
109
|
+
await once(async () => {
|
|
110
|
+
const close = makeClose(closeWithAnimation);
|
|
111
|
+
await onConfirm(close.close);
|
|
112
112
|
if (closeOnConfirm)
|
|
113
113
|
closeWithAnimation();
|
|
114
|
-
return closeOnConfirm ||
|
|
114
|
+
return closeOnConfirm || close.didClose();
|
|
115
115
|
});
|
|
116
116
|
},
|
|
117
117
|
},
|
|
118
|
-
], children: [topContent != null && _jsx("div", { className: "confirm-popup__top", children: topContent }), details && details.length > 0 && (_jsx(Panel, { children: details.map((d, i) => (_jsx(PanelRow, { label: d.label, children: _jsx("span", { className: "panel-row__text body-2", children: d.value }) }, i))) })), _jsx("p", { ref:
|
|
118
|
+
], children: [topContent != null && _jsx("div", { className: "confirm-popup__top", children: topContent }), details && details.length > 0 && (_jsx(Panel, { children: details.map((d, i) => (_jsx(PanelRow, { label: d.label, children: _jsx("span", { className: "panel-row__text body-2", children: d.value }) }, i))) })), _jsx("p", { ref: bodyRef, className: "body-2 confirm-popup__message", children: message }), children] }));
|
|
119
119
|
}
|
|
@@ -13,7 +13,7 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
13
13
|
const [error, setError] = useState(null);
|
|
14
14
|
const inputRef = useRef(null);
|
|
15
15
|
/* 글귀와 칸을 잇는 id — 한 쪽에 이 창이 둘 설 일은 없지만 id 는 문서에서 하나여야 한다 */
|
|
16
|
-
const
|
|
16
|
+
const hintId = useId();
|
|
17
17
|
// Popup 자체 포커스 로직(패널 내 첫 focusable = 헤더 닫기 버튼)보다 먼저 실행되도록
|
|
18
18
|
// 자식 effect가 부모보다 먼저 도는 순서를 이용해 입력창을 선점 포커스한다.
|
|
19
19
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
@@ -46,5 +46,5 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
46
46
|
return (_jsx(Popup, { className: "coupon-input-popup", title: t('usage.couponButton', '쿠폰 입력'), size: "small", onClose: onCancel, closeDisabled: loading, buttons: [
|
|
47
47
|
{ label: t('common.cancel', '취소'), variant: 'default', disabled: loading, onClick: ({ closeWithAnimation }) => closeWithAnimation() },
|
|
48
48
|
{ label: t('coupons.applyButton', '적용'), variant: 'main', disabled: !code.trim() || loading, onClick: ({ closeWithAnimation }) => void handleApply(closeWithAnimation) },
|
|
49
|
-
], children: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, className: "coupon-input-popup__input body-2", "aria-label": t('coupons.input.label', '쿠폰 코드'), "aria-invalid": error ? true : undefined, "aria-errormessage": error ?
|
|
49
|
+
], children: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, className: "coupon-input-popup__input body-2", "aria-label": t('coupons.input.label', '쿠폰 코드'), "aria-invalid": error ? true : undefined, "aria-errormessage": error ? hintId : undefined, "aria-describedby": error ? hintId : undefined, placeholder: t('coupons.input.placeholder', '쿠폰 코드 입력'), value: code, onChange: handleChange, onKeyDown: e => handleKey(e, closeWithAnimation), disabled: loading, autoComplete: "off", spellCheck: false }), error && _jsx("p", { id: hintId, className: "coupon-input-popup__error caption-1", role: "alert", children: error })] })) }));
|
|
50
50
|
}
|
|
@@ -7,15 +7,8 @@ import Table from '../Table';
|
|
|
7
7
|
import Button from '../Button';
|
|
8
8
|
import Checkbox from '../Checkbox';
|
|
9
9
|
import Badge from '../Badge';
|
|
10
|
-
import { deviceKindVariant } from '../../utils/deviceKind';
|
|
10
|
+
import { deviceKindVariant, deviceKindLabel } from '../../utils/deviceKind';
|
|
11
11
|
import { formatWornDate, wornDateSortKey } from '../../utils/wornDate';
|
|
12
|
-
/* 기기 종류 배지 이름은 지금 언어로 — 값은 저장된 한국어(보청기·인공와우)라 그대로 보이면 영어 화면에도 한국어였다.
|
|
13
|
-
색(deviceKindVariant)은 저장값으로 가른다. 모르는 값은 그대로 */
|
|
14
|
-
const 기기종류열쇠 = { '보청기': 'hearingAid', '인공와우': 'cochlearImplant' };
|
|
15
|
-
function 기기종류이름(device, t) {
|
|
16
|
-
const k = 기기종류열쇠[device];
|
|
17
|
-
return k ? t(`device.type.${k}`, { defaultValue: device }) : device;
|
|
18
|
-
}
|
|
19
12
|
import { useTablePaging, useTableSort } from '../../hooks/useTablePaging';
|
|
20
13
|
import { compareCellBlankLast } from '../../utils/textSort';
|
|
21
14
|
import './DeviceListPopup.css';
|
|
@@ -23,7 +16,7 @@ import './DeviceListPopup.css';
|
|
|
23
16
|
* 정렬에 쓸 문자열. 날짜는 **자리수를 채워** 잰다 — '2026.5.2' 와 '2026.11.5' 를
|
|
24
17
|
* 글자 그대로 견주면 5월이 11월보다 뒤로 간다.
|
|
25
18
|
*/
|
|
26
|
-
function
|
|
19
|
+
function sortValue(r, key) {
|
|
27
20
|
if (key !== 'wornAt')
|
|
28
21
|
return String(r[key] ?? '');
|
|
29
22
|
/* 월·일이 null 로 오는 옛 기록이 있다 — padStart 에서 표가 통째로 터졌다 */
|
|
@@ -31,36 +24,36 @@ function 정렬값(r, key) {
|
|
|
31
24
|
}
|
|
32
25
|
export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }) {
|
|
33
26
|
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
34
|
-
const
|
|
27
|
+
const sideLabelOf = (side) => side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
|
|
35
28
|
/* 머리행을 누르면 그 칸으로 정렬한다 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
|
|
36
29
|
쪽넘김보다 **앞에서** 정렬한다(훅에 이미 정렬된 줄을 건넨다). */
|
|
37
|
-
const {
|
|
30
|
+
const { sort, field } = useTableSort();
|
|
38
31
|
const sortedRows = useMemo(() => {
|
|
39
|
-
if (
|
|
32
|
+
if (!sort)
|
|
40
33
|
return rows;
|
|
41
|
-
const { key, asc } =
|
|
34
|
+
const { key, asc } = sort;
|
|
42
35
|
return [...rows].sort((a, b) => {
|
|
43
36
|
if (key === 'worn') {
|
|
44
37
|
const cmp = Number(b.worn) - Number(a.worn); // 켜진 것이 먼저
|
|
45
38
|
return asc ? cmp : -cmp;
|
|
46
39
|
}
|
|
47
|
-
return compareCellBlankLast(
|
|
40
|
+
return compareCellBlankLast(sortValue(a, key), sortValue(b, key), asc); // 빈 칸(착용일 없음 등)은 오름·내림 모두 맨 뒤 — useTableSort 와 같은 규칙
|
|
48
41
|
});
|
|
49
|
-
}, [rows,
|
|
42
|
+
}, [rows, sort]);
|
|
50
43
|
/* 개수 보기·쪽넘김은 방문 표와 같은 것을 쓴다 — 같은 자리에서 같은 뜻으로 읽히게 */
|
|
51
|
-
const { pageRows, pageSizeProps, paginationProps } = useTablePaging(sortedRows,
|
|
44
|
+
const { pageRows, pageSizeProps, paginationProps } = useTablePaging(sortedRows, sort);
|
|
52
45
|
return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { loading: loading, resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
|
|
53
46
|
/* 맨 왼쪽 — 지금 끼고 있는 것만 좌/우를 적는다. 이 열만 훑으면 "오늘 이 사람이
|
|
54
47
|
무엇을 쓰고 있나" 가 한눈에 보인다(나머지 줄은 비어 있어 눈에 안 걸린다). */
|
|
55
48
|
/* 체크박스 칸은 폭을 늘릴 것이 없다 — 손잡이를 주면 잡히는데 아무 일도
|
|
56
49
|
일어나지 않는다. Table 이 이 경우를 위해 resizable:false 를 둔다. */
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
{ ...field(t('customers.device.wearingNow', '착용'), 'worn'), resizable: false },
|
|
51
|
+
field(t('device.direction.label', '방향'), 'side'),
|
|
52
|
+
field(t('device.deviceType', '종류'), 'device'),
|
|
53
|
+
field(t('device.brand', '브랜드'), 'brand'),
|
|
54
|
+
field(t('device.fitType', '착용형태'), 'fitType'),
|
|
55
|
+
field(t('device.productName', '제품명'), 'productName'),
|
|
56
|
+
field(t('device.wornDate', '착용일'), 'wornAt'),
|
|
64
57
|
/* 삭제 칸은 그 길을 준 화면에서만 — 머리행과 줄의 칸 수가 어긋나면 표가 밀린다 */
|
|
65
58
|
...(onDelete ? [{ label: '', resizable: false }] : []),
|
|
66
59
|
], items: pageRows.map((r, i) => ({ id: r.id ?? `${r.side}-${i}`, row: r })), gridTemplateColumns: `56px 110px 110px minmax(120px, 1fr) 100px minmax(120px, 1fr) 110px${onDelete ? ' 80px' : ''}`, renderRow: ({ row }) => [
|
|
@@ -79,11 +72,11 @@ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onW
|
|
|
79
72
|
onRelease?.(row); } }) })),
|
|
80
73
|
},
|
|
81
74
|
/* 방향 칸은 좌/우만 말한다 — 착용 여부는 맨 왼쪽 체크가 다룬다 */
|
|
82
|
-
{ content: _jsx("span", { children:
|
|
75
|
+
{ content: _jsx("span", { children: sideLabelOf(row.side) }) },
|
|
83
76
|
/* 종류는 위 패널의 종류 배지와 같은 모양으로 — 같은 값을 두 곳에서 다르게
|
|
84
77
|
그리면 같은 것인 줄 모른다. 값이 비면 배지도 세우지 않는다(빈 배지는
|
|
85
78
|
'무언가 있다'는 거짓 신호다). */
|
|
86
|
-
{ content: row.device ? _jsx(Badge, { size: "small", variant: deviceKindVariant(row.device), fill: "soft", label:
|
|
79
|
+
{ content: row.device ? _jsx(Badge, { size: "small", variant: deviceKindVariant(row.device), fill: "soft", label: deviceKindLabel(row.device, t) }) : _jsx("span", {}) },
|
|
87
80
|
{ content: _jsx("span", { children: row.brand }) },
|
|
88
81
|
{ content: _jsx("span", { children: row.fitType || '-' }) },
|
|
89
82
|
{ content: _jsx("span", { children: row.productName || '-' }) },
|
|
@@ -58,8 +58,8 @@ export default function ListPopup(props) {
|
|
|
58
58
|
떴다. 렌더 중에 상태도 맞춘다. */
|
|
59
59
|
if (page > totalPages)
|
|
60
60
|
setPage(totalPages);
|
|
61
|
-
const
|
|
62
|
-
const paged = filtered.slice((
|
|
61
|
+
const clampedPage = Math.min(page, totalPages);
|
|
62
|
+
const paged = filtered.slice((clampedPage - 1) * Number(pageSize), clampedPage * Number(pageSize));
|
|
63
63
|
const handleSort = useCallback((key) => {
|
|
64
64
|
if (sortKey === key)
|
|
65
65
|
setSortAsc(a => !a);
|
|
@@ -99,7 +99,7 @@ export default function ListPopup(props) {
|
|
|
99
99
|
sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
|
|
100
100
|
})), [columns, sortKey, sortAsc, handleSort]);
|
|
101
101
|
const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
|
|
102
|
-
return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page:
|
|
102
|
+
return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: clampedPage, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, mobileGridTemplateColumns: mobileGridTemplateColumns,
|
|
103
103
|
/* 폭 조절은 켜되 **저장 열쇠는 안 준다** — 창은 열 때마다 새로 짓는 것이라 폭이 따라다니면
|
|
104
104
|
엉뚱하다. 그래서 이 창의 표는 끌어도 localStorage 에 아무 자국이 없고, 끈 폭은 창이 닫히면
|
|
105
105
|
사라진다. 폰에서 '오늘의 듣기 현황 상세보기' 가 `48px 48px …` 로 눌렸는데 저장소가
|
|
@@ -77,20 +77,20 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
77
77
|
cardEls.current.set(id, el);
|
|
78
78
|
else
|
|
79
79
|
cardEls.current.delete(id); };
|
|
80
|
-
const
|
|
80
|
+
const focusAfterDeleteRef = useRef(null);
|
|
81
81
|
useEffect(() => {
|
|
82
|
-
const p =
|
|
82
|
+
const p = focusAfterDeleteRef.current;
|
|
83
83
|
if (!p || sorted.some(t => t.id === p.id))
|
|
84
84
|
return;
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
cardEls.current.get(
|
|
85
|
+
focusAfterDeleteRef.current = null;
|
|
86
|
+
const nextId = [p.next, p.prev].find(id => id != null && sorted.some(t => t.id === id));
|
|
87
|
+
if (nextId)
|
|
88
|
+
cardEls.current.get(nextId)?.focus();
|
|
89
89
|
}, [sorted]);
|
|
90
90
|
const handleDelete = useCallback((id, e) => {
|
|
91
91
|
e.stopPropagation();
|
|
92
92
|
const i = sorted.findIndex(t => t.id === id);
|
|
93
|
-
|
|
93
|
+
focusAfterDeleteRef.current = { id, next: sorted[i + 1]?.id ?? null, prev: i > 0 ? sorted[i - 1].id : null };
|
|
94
94
|
if (onDeleteProp) {
|
|
95
95
|
onDeleteProp(id);
|
|
96
96
|
}
|