@ihabdevteam/core 0.135.0 → 0.137.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +3 -3
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +38 -7
  8. package/dist/packages/core/src/components/Alert.js +1 -1
  9. package/dist/packages/core/src/components/Breadcrumb.js +1 -0
  10. package/dist/packages/core/src/components/Button.css +5 -0
  11. package/dist/packages/core/src/components/Button.js +3 -3
  12. package/dist/packages/core/src/components/Checkbox.js +10 -2
  13. package/dist/packages/core/src/components/CodeBox.js +2 -1
  14. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +5 -2
  15. package/dist/packages/core/src/components/Icon.js +4 -1
  16. package/dist/packages/core/src/components/IconGridMenu.js +25 -22
  17. package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
  18. package/dist/packages/core/src/components/LandingHeader.js +5 -1
  19. package/dist/packages/core/src/components/PageTitle.js +1 -0
  20. package/dist/packages/core/src/components/Pagination.js +1 -1
  21. package/dist/packages/core/src/components/Panel.css +3 -0
  22. package/dist/packages/core/src/components/Panel.d.ts +11 -1
  23. package/dist/packages/core/src/components/Panel.js +3 -3
  24. package/dist/packages/core/src/components/PanelRow.js +8 -1
  25. package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
  26. package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
  27. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
  28. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
  29. package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
  30. package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
  31. package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
  32. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
  33. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -1
  34. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
  35. package/dist/packages/core/src/components/Popup/Popup.css +19 -0
  36. package/dist/packages/core/src/components/Popup/Popup.d.ts +14 -1
  37. package/dist/packages/core/src/components/Popup/Popup.js +6 -5
  38. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
  39. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
  40. package/dist/packages/core/src/components/ProgressBar.js +5 -2
  41. package/dist/packages/core/src/components/ProgressIndicator.js +7 -1
  42. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
  43. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
  44. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
  45. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
  46. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
  47. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
  48. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
  49. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
  50. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
  51. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
  52. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
  53. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
  54. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
  55. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
  56. package/dist/packages/core/src/components/Rating.js +3 -1
  57. package/dist/packages/core/src/components/SideNav.js +6 -3
  58. package/dist/packages/core/src/components/SplashLogo.js +1 -2
  59. package/dist/packages/core/src/components/StatChart.js +1 -1
  60. package/dist/packages/core/src/components/TextField.js +3 -1
  61. package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
  63. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
  64. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +15 -7
  65. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -0
  66. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
  67. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +28 -1
  68. package/dist/packages/core/src/components/Training/TrainingStepCard.js +10 -3
  69. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
  70. package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
  71. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
  72. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
  73. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
  74. package/dist/packages/core/src/components/Training/TrainingWordCard.js +21 -4
  75. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
  76. package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
  77. package/dist/packages/core/src/components/Training/currentSound.js +13 -0
  78. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
  79. package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
  80. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
  81. package/dist/packages/core/src/components/Training/useNoiseFade.js +4 -0
  82. package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
  83. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  84. package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
  85. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
  86. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
  87. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
  88. package/dist/packages/core/src/components/WordTable.js +19 -5
  89. package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
  90. package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
  91. package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
  92. package/dist/packages/core/src/context/I18nProvider.js +12 -3
  93. package/dist/packages/core/src/hooks/useFontScale.js +13 -4
  94. package/dist/packages/core/src/hooks/useIcon.js +3 -0
  95. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
  96. package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
  97. package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
  98. package/dist/packages/core/src/hooks/usePagedList.js +1 -0
  99. package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
  100. package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
  101. package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
  102. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
  103. package/dist/packages/core/src/i18n/locales/en.json +42 -4
  104. package/dist/packages/core/src/i18n/locales/ko.json +42 -4
  105. package/dist/packages/core/src/utils/date.js +26 -5
  106. package/dist/packages/core/src/utils/errorMessage.js +7 -1
  107. package/dist/packages/core/src/utils/speak.js +23 -3
  108. package/dist/packages/core/src/utils/textSort.js +12 -1
  109. package/dist/packages/core/src/utils/ttsConfig.js +10 -8
  110. package/dist/packages/core/src/version.d.ts +1 -1
  111. package/dist/packages/core/src/version.js +1 -1
  112. package/dist/utils.cjs.js +2 -2
  113. package/package.json +1 -1
@@ -34,6 +34,25 @@
34
34
  .popup-panel--small { max-width: 480px; }
35
35
  .popup-panel--medium { max-width: 680px; }
36
36
  .popup-panel--large { max-width: 1080px; }
37
+ .popup-panel--xlarge { width: calc(100% - 80px); max-width: 1320px; }
38
+ /* height prop — 변수로 받는다. 클래스 하나라 앱이 `.my-popup .popup-panel { height }` 로 (미디어별로) 이길 수 있다 */
39
+ .popup-panel--has-height { height: var(--popup-height); }
40
+ /* fillBody — 본문은 굴리지 않고 안쪽이 높이를 물려받는다(안의 한 칸만 굴린다) */
41
+ .popup-panel--fill-body .popup-content { overflow: hidden; }
42
+ .popup-panel--fill-body .popup-content__inner {
43
+ display: flex;
44
+ flex-direction: column;
45
+ min-height: 0;
46
+ height: 100%;
47
+ }
48
+ /* fit — 내용 폭. 최소·최대 모두 화면 폭 - 40px 을 넘지 않는다(좁은 화면에서 480px 로 잘리지 않게) */
49
+ .popup-panel--fit {
50
+ width: fit-content;
51
+ min-width: min(480px, calc(100% - 40px));
52
+ max-width: min(1140px, calc(100% - 40px));
53
+ }
54
+ /* 층 — 훈련 페이지 팝업 바로 아래(그 창이 이 창을 덮는다). inline 은 제 자리 규칙을 따른다 */
55
+ .popup.popup--below-page-popup:not(.popup--inline) { z-index: calc(var(--z-page-popup) - 1); }
37
56
  .popup-close-float {
38
57
  position: absolute;
39
58
  top: 8px;
@@ -70,9 +70,17 @@ export interface PopupProps {
70
70
  * - `small` 480px (확인/알림류)
71
71
  * - `medium` 680px (입력 폼)
72
72
  * - `large` 1080px (리스트/다중 콘텐츠)
73
+ * - `xlarge` 1320px, 폭은 화면 - 80px(좌우 40px) — 훈련 카드 만들기처럼 화면을 넓게 쓰는 창
74
+ * - `fit` 내용 폭에 맞춘다 — 최소 480px·최대 1140px(둘 다 화면 폭 - 40px 을 넘지 않는다).
75
+ * 한 줄에 카드가 몇 장 들어가느냐로 폭이 정해지는 목록 창에 쓴다.
73
76
  * 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
74
77
  */
75
- size?: 'small' | 'medium' | 'large';
78
+ size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fit';
79
+ /**
80
+ * 쌓이는 층. 기본 `modal`(`--z-modal`) 은 훈련 페이지 팝업(`--z-page-popup`) 위에 뜬다.
81
+ * `belowPagePopup` 은 그 **바로 아래** 에 깐다 — 이 창에서 연 훈련 팝업이 이 창을 덮어야 할 때(실행 창 등).
82
+ */
83
+ layer?: 'modal' | 'belowPagePopup';
76
84
  /**
77
85
  * 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
78
86
  *
@@ -81,6 +89,11 @@ export interface PopupProps {
81
89
  * 그대로 걸리므로 창이 화면 밖으로 나가지 않는다. 넘치는 내용은 본문이 스스로 굴린다.
82
90
  */
83
91
  height?: number | string;
92
+ /**
93
+ * 본문이 패널 높이를 채운다 — 본문 자신은 굴리지 않고, 안쪽을 세로 flex(높이 100%)로 넘겨 **안의 한 칸만** 굴리게 한다.
94
+ * `height` 로 창 높이를 박고 가운데 목록(`Panel scrollable` 등)만 굴러가야 하는 창에 쓴다.
95
+ */
96
+ fillBody?: boolean;
84
97
  /**
85
98
  * 이 값이 바뀌면 본문(.popup-content) 스크롤을 맨 위로 되돌린다.
86
99
  * 탭/모드 전환처럼 내부 콘텐츠가 통째로 바뀌는 팝업에서, 스크롤을 내린 채
@@ -13,7 +13,7 @@ const ANIM_MS = 260;
13
13
  /** @public */
14
14
  export default function Popup(props) {
15
15
  const { t } = useTranslation();
16
- const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, height, resetScrollKey } = props || {};
16
+ const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
17
17
  const [closing, setClosing] = useState(false);
18
18
  // 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
19
19
  // 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
@@ -154,14 +154,15 @@ export default function Popup(props) {
154
154
  if (pressed && e.target === e.currentTarget)
155
155
  closeWithAnimation();
156
156
  };
157
- const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
157
+ const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${layer === 'belowPagePopup' ? 'popup--below-page-popup' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
158
158
  /* 바탕을 눌러도 포커스는 판 안에 둔다 — 닫히지 않는 바탕(closeOnBackdrop={false}·closeDisabled·beforeClose 가
159
159
  거절)을 누르면 포커스가 body 로 떨어져, 이어서 누른 Tab 이 뒤 화면으로 나갔다(가둠은 판 안의 키만 듣는다).
160
160
  mousedown 의 기본 동작(포커스 옮김)만 막고 click 은 그대로 온다. */
161
161
  onMouseDown: inline ? undefined : (e) => { if (e.target === e.currentTarget)
162
- e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`,
163
- /* max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
164
- style: height != null ? { height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
162
+ e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}${height != null ? ' popup-panel--has-height' : ''}${fillBody ? ' popup-panel--fill-body' : ''}`,
163
+ /* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
164
+ max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
165
+ style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
165
166
  if (inline || typeof document === 'undefined')
166
167
  return el;
167
168
  return createPortal(el, document.body);
@@ -64,8 +64,13 @@ export default function TrainingResultPopup(props) {
64
64
  useEffect(() => {
65
65
  const total = contextsLength ?? 0;
66
66
  if (score != null && total > 0 && score / total > 0.5) {
67
- window.dispatchEvent(new CustomEvent('party:trigger', { detail: { count: 140, strength: 1.6, duration: 2200 } }));
67
+ // 한 틱 미뤄 보내고 떠나면 걷는다 — StrictMode 의 두 번 실행에서 폭죽 신호가 두 번 나갔다
68
+ const id = window.setTimeout(() => {
69
+ window.dispatchEvent(new CustomEvent('party:trigger', { detail: { count: 140, strength: 1.6, duration: 2200 } }));
70
+ }, 0);
71
+ return () => window.clearTimeout(id);
68
72
  }
73
+ return undefined;
69
74
  }, [score, contextsLength]);
70
75
  const getJudge = useCallback((it) => {
71
76
  const decision = resolveDecision(it);
@@ -77,10 +77,12 @@ export function visitItemKey(v) {
77
77
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
78
78
  */
79
79
  export function formatDuration(sec, durationStr) {
80
- if (!sec || sec <= 0)
80
+ // 초 단위로 반올림한 뒤 나눈다 — 90.5 가 "1분 30.5초", 59.999 가 "59.999초" 로 찍혔다
81
+ const 초 = Number.isFinite(sec) ? Math.round(sec) : 0;
82
+ if (!초 || 초 <= 0)
81
83
  return '-';
82
- const m = Math.floor(sec / 60);
83
- const s = sec % 60;
84
+ const m = Math.floor(초 / 60);
85
+ const s = 초 % 60;
84
86
  if (durationStr)
85
87
  return durationStr(m, s);
86
88
  // 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
@@ -24,6 +24,9 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
24
24
  const safeMax = Number.isFinite(max) && max > 0 ? max : 1;
25
25
  const clamped = Math.max(0, Math.min(Number.isFinite(value) ? value : 0, safeMax));
26
26
  const pct = (clamped / safeMax) * 100;
27
+ /* 글자·aria 의 최댓값은 **받은 값** 이다(0 이하·못 읽는 값은 0) — safeMax 는 나눗셈을 지키려는 것뿐인데 라벨에도 써서
28
+ 항목이 아직 없을 때(max=0) "0 / 1" 로 읽혔다 */
29
+ const shownMax = Number.isFinite(max) && max > 0 ? max : 0;
27
30
  // 자리를 지정하지 않으면 굵기가 정한다 — 얇은 막대 안에는 글자가 들어가지 않는다.
28
31
  // center 는 inside 의 다른 이름이라 여기서 하나로 모은다(클래스가 갈리지 않게).
29
32
  const rawPos = labelPos ?? (size >= INSIDE_LABEL_MIN_SIZE ? 'inside' : 'bottom');
@@ -43,9 +46,9 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
43
46
  rootStyle['--progress-bar-fill'] = color;
44
47
  if (trackColor)
45
48
  rootStyle['--progress-bar-track'] = trackColor;
46
- return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": safeMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
49
+ return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
47
50
  ? formatValue(clamped)
48
51
  : label === 'percent' ? `${Math.round(pct)}%`
49
52
  : label === 'raw' ? String(clamped)
50
- : `${clamped} / ${safeMax}` }))] }));
53
+ : `${clamped} / ${shownMax}` }))] }));
51
54
  }
@@ -1,7 +1,13 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import useMediaQuery from '../hooks/useMediaQuery';
4
5
  import './ProgressIndicator.css';
6
+ /** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
7
+ const 보이지않게 = {
8
+ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
9
+ clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
10
+ };
5
11
  /**
6
12
  * transparent 일 때 남은 단계의 배경.
7
13
  *
@@ -42,7 +48,7 @@ export default function ProgressIndicator({ max, index, inactiveBg, inactiveColo
42
48
  묶고 이름표에 자리를 적는다. 화면이 스스로 넘어가는 자리에서는
43
49
  announce 로 role="status" 를 켠다. */
44
50
  if (variant === 'dot') {
45
- return (_jsx("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i))) }));
51
+ return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: 보이지않게, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
46
52
  }
47
53
  // 모바일: 현재 단계만 표시 (n / max 형태)
48
54
  if (isMobile) {
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PromoBannerBg.css';
@@ -79,7 +79,9 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
79
79
  const star = i + 1;
80
80
  const fillRatio = Math.max(0, Math.min(1, displayValue - i));
81
81
  const isHalf = fillRatio > 0 && fillRatio < 1;
82
- const starNode = (_jsxs("span", { className: "rating__star-wrap", children: [_jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--empty" }), fillRatio > 0 && (_jsx("span", { className: `rating__star-fill${isHalf ? ' rating__star-fill--half' : ''}`, children: _jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--filled" }) }))] }));
82
+ const starNode = (_jsxs("span", { className: "rating__star-wrap", children: [_jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--empty" }), fillRatio > 0 && (_jsx("span", { className: `rating__star-fill${isHalf ? ' rating__star-fill--half' : ''}`,
83
+ // 조각 별은 **값만큼** 채운다 — 반 별 클래스(50%)만 두어 4.1 과 4.9 가 같은 반 별로 그려졌다(이름은 "4.1")
84
+ style: isHalf ? { width: `${fillRatio * 100}%` } : undefined, children: _jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--filled" }) }))] }));
83
85
  if (isReadOnly) {
84
86
  return (_jsx("span", { className: "rating__item", "aria-hidden": "true", children: starNode }, star));
85
87
  }
@@ -57,7 +57,10 @@ function SideNavButton(props) {
57
57
  export default function SideNav(props) {
58
58
  const { t } = useTranslation();
59
59
  const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange } = props || {};
60
- const handleModeSwitch = useCallback(() => onAppModeChange?.(appMode === 'malgwi' ? 'manager' : 'malgwi'), [onAppModeChange, appMode]);
60
+ /* 모드를 안 주면 말귀로 본다 — Header(브랜드)·BottomNav 와 같게. 전에는 여기만 매니저로 봐 같은 화면에서
61
+ 데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
62
+ const isManager = appMode === 'manager';
63
+ const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
61
64
  return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
62
65
  .filter((it) => !it.section && (it.key || it.label))
63
66
  .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
@@ -113,7 +116,7 @@ export default function SideNav(props) {
113
116
  }
114
117
  } })] }, it.key ?? `side-nav__item-${idx}`))), onAppModeChange && _jsx("div", { className: "side-nav__separator side-nav__separator--short" }), onAppModeChange && (_jsx(SideNavButton, { item: {
115
118
  key: '__mode-switch__',
116
- label: appMode === 'malgwi' ? t('nav.goToManager', '말귀매니저로 이동') : t('nav.goToMalgwi', '말귀로 이동'),
117
- icon: appMode === 'malgwi' ? 'arrow-right' : 'arrow-left',
119
+ label: isManager ? t('nav.goToMalgwi', '말귀로 이동') : t('nav.goToManager', '말귀매니저로 이동'),
120
+ icon: isManager ? 'arrow-left' : 'arrow-right',
118
121
  }, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp })] }));
119
122
  }
@@ -33,8 +33,7 @@ export default function SplashLogo({ logo, alt, onDone, durationMs = 3000, }) {
33
33
  // 담는다. 타이머는 한 번만 걸고, 울릴 때 **그때의 최신 콜백**을 부른다.
34
34
  const onDoneRef = useLatestRef(onDone);
35
35
  useEffect(() => {
36
- if (!onDoneRef.current)
37
- return;
36
+ // onDone 이 없어도 타이머는 건다 — 처음에 없다가 나중에 들어오면(인증 확인 뒤 콜백을 넘기는 등) 전에는 끝내 안 걸려 스플래시가 남았다
38
37
  const timer = setTimeout(() => onDoneRef.current?.(), durationMs);
39
38
  return () => clearTimeout(timer);
40
39
  }, [durationMs, onDoneRef]);
@@ -30,7 +30,7 @@ export default function StatChart({ label, value, change, changeType, icon, tool
30
30
  }, [onClick]);
31
31
  return (_jsxs("div", { ref: cardRef, className: `manager-stat-card${onClick ? ' manager-stat-card--clickable' : ''}`, onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, onPointerEnter: handleAnchorEnter, onPointerLeave: handleAnchorLeave,
32
32
  // hover 전용이면 키보드로는 툴팁 내용을 볼 수 없다(ManagerPage와 동일 처리)
33
- onFocus: handleAnchorEnter, onBlur: handleAnchorLeave, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${label} ${value}` : undefined, children: [tooltip && anchorRect && (_jsx(TooltipInfo, { anchorRect: {
33
+ onFocus: handleAnchorEnter, onBlur: handleAnchorLeave, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? [label, value, changeText].filter((v) => v !== undefined && v !== '').join(' ') : undefined, children: [tooltip && anchorRect && (_jsx(TooltipInfo, { anchorRect: {
34
34
  left: anchorRect.left,
35
35
  top: anchorRect.top + 21,
36
36
  right: anchorRect.right,
@@ -8,6 +8,8 @@ import Icon from './Icon';
8
8
  import Button from './Button';
9
9
  import { useComboboxSuggestions } from './useComboboxSuggestions';
10
10
  import './TextField.css';
11
+ /* 서버 렌더에서는 layout effect 가 돌지 않고 React 18 은 경고를 찍는다 — 브라우저에서만 layout, 서버에서는 effect */
12
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
11
13
  /** @public */
12
14
  export default function TextField(props) {
13
15
  const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions, openOnFocus = false, } = props;
@@ -61,7 +63,7 @@ export default function TextField(props) {
61
63
  돌아, 부모가 같은 값을 다시 두면(공백을 걷는 등) 화면 칸에 친 글자가 남았다. React 가 일반 칸에
62
64
  해 주는 되돌림(restoreControlledState)을 onChange 뒤 한 박자에 흉내 낸다. */
63
65
  const valueRef = useRef(value);
64
- useLayoutEffect(() => { valueRef.current = value; });
66
+ useIsoLayoutEffect(() => { valueRef.current = value; });
65
67
  /*
66
68
  * React 는 controlled input 의 값을 node.defaultValue(= HTML value 속성)에 동기화한다.
67
69
  * 일반 입력에서는 무해하지만 비밀번호에서는 그 속성이 outerHTML 직렬화 경로
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import Tooltip from './Tooltip';
3
4
  import { useTranslation } from 'react-i18next';
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Tooltip from './Tooltip';
6
6
  import './TrainingTooltip.css';
7
- import { TRAINING_TYPES } from '../../utils/CategoryData';
7
+ import { translateTrainingTypes } from '../../utils/CategoryData';
8
8
  import { getTrainingPools } from '../../utils/getTrainingData';
9
9
  import TrainingBlockButton from '../Training/TrainingBlockButton';
10
10
  /** 타입 칩 호버 → TrainingTooltip 표시까지의 지연(ms). 말귀자료실 카드와
@@ -20,10 +20,11 @@ function findTrainingData(sel) {
20
20
  /** @public */
21
21
  export default function TrainingTooltip({ plan, anchorRect, offset = 12, onClose }) {
22
22
  const { t } = useTranslation();
23
- const groups = useMemo(() => TRAINING_TYPES.map(t => ({
24
- type: t,
25
- items: (plan.items ?? []).filter(i => i.trainingType === t.key),
26
- })).filter(g => g.items.length > 0), [plan.items]);
23
+ // 머리글은 지금 언어로 — 원본 목록의 한국어 라벨을 써 영어 화면에도 '짧은 이야기 듣기' 가 떴다
24
+ const groups = useMemo(() => translateTrainingTypes(t).map(type => ({
25
+ type,
26
+ items: (plan.items ?? []).filter(i => i.trainingType === type.key),
27
+ })).filter(g => g.items.length > 0), [plan.items, t]);
27
28
  if (groups.length === 0)
28
29
  return null;
29
30
  const box = (_jsx("div", { className: "tooltip-training__box", children: _jsx("div", { className: "tooltip-training__groups", children: groups.flatMap(({ type, items }, idx) => [
@@ -278,6 +278,63 @@
278
278
  justify-content: center;
279
279
  }
280
280
 
281
+ /* ── row 변형 — 전체 너비 가로 행 ─────────────────────────────────────────────────
282
+ 정사각형 카드는 폭이 줄면 내용이 눌린다. 좁은 화면 목록에서 한 줄에 한 장씩
283
+ [아이콘][유형/제목][문항수] 를 모두 보이게 grid 로 다시 놓는다. 미리 듣기 단추는 그리지 않는다. */
284
+ .training-block-button--row {
285
+ width: 100%;
286
+ height: auto;
287
+ min-height: 68px;
288
+ display: flex;
289
+ align-items: center;
290
+ }
291
+ .training-block-button--row .training-block-button__left {
292
+ display: grid;
293
+ grid-template-columns: auto 1fr auto;
294
+ grid-template-areas:
295
+ "icon type count"
296
+ "icon title count";
297
+ align-items: center;
298
+ align-content: center;
299
+ height: auto;
300
+ column-gap: var(--spacing-s);
301
+ row-gap: 4px;
302
+ padding: 8px 0;
303
+ }
304
+ .training-block-button--row .category-icon__wrap {
305
+ grid-area: icon;
306
+ align-self: center;
307
+ }
308
+ .training-block-button--row .training-block-button__type-label {
309
+ grid-area: type;
310
+ align-self: end;
311
+ position: static;
312
+ inset: auto;
313
+ }
314
+ .training-block-button--row .training-block-button__title {
315
+ grid-area: title;
316
+ align-self: start;
317
+ position: static;
318
+ inset: auto;
319
+ white-space: nowrap;
320
+ overflow: hidden;
321
+ text-overflow: ellipsis;
322
+ }
323
+ .training-block-button--row .training-block-button__description {
324
+ grid-area: count;
325
+ display: flex;
326
+ align-items: center;
327
+ gap: 4px;
328
+ position: static;
329
+ inset: auto;
330
+ height: auto;
331
+ }
332
+ /* 유형 줄이 없으면 제목이 두 줄 자리 가운데에 선다 */
333
+ .training-block-button--row:not(.training-block-button--withType) .training-block-button__title {
334
+ grid-row: 1 / span 2;
335
+ align-self: center;
336
+ }
337
+
281
338
  /* preview play button: small 카드 우측 상단 — 좌측 아이콘(top:12)과 수평 정렬 */
282
339
  .training-block-button--small .training-block-button__preview {
283
340
  position: absolute;
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { getTopicTypes, getTrainingTypes } from '../../utils/categories';
6
+ import { TRAINING_TYPE_I18N_KEY } from '../../utils/CategoryData';
6
7
  import Icon from '../Icon';
7
8
  import Button from '../Button';
8
9
  import CategoryIcon from './CategoryIcon';
@@ -93,9 +94,11 @@ export default function TrainingBlockButton(props) {
93
94
  const c = topic.iconColor;
94
95
  wrapperStyle = { ['--t-item-fg']: c };
95
96
  }
96
- mainName = parent?.label || '';
97
+ // 카테고리 이름도 지금 언어로(topic.* · topic.sub.*, 사전에 없으면 등록된 이름)
98
+ mainName = parent ? t(`topic.${parent.key}`, { defaultValue: parent.label || '' }) : '';
97
99
  try {
98
- subName = parent?.subs?.find((s) => s.key === subKey)?.label || '';
100
+ const sub = parent?.subs?.find((s) => s.key === subKey);
101
+ subName = sub ? t(`topic.sub.${sub.key}`, { defaultValue: sub.label || '' }) : '';
99
102
  }
100
103
  catch (_) { }
101
104
  }
@@ -167,20 +170,25 @@ export default function TrainingBlockButton(props) {
167
170
  }
168
171
  }
169
172
  catch (_) { }
170
- const isList = size === 'list';
173
+ const isRow = size === 'row';
174
+ // row 는 list 의 바탕 위에 배치만 바꾼다
175
+ const isList = size === 'list' || isRow;
171
176
  const isCompact = size === 'compact';
172
177
  const isSmall = size === 'small';
173
- const trainingTypeName = isSmall && type?.key
174
- ? (getTrainingTypes().find(t => t.key === type.key)?.label ?? '')
178
+ // 유형 이름은 지금 언어로 — 등록된 원본의 한국어 라벨을 써 영어 화면 계획 바구니에 '소음 하 문장 듣기' 가 떴다
179
+ const 유형원본 = isSmall && type?.key ? getTrainingTypes().find(x => x.key === type.key) : undefined;
180
+ const 유형열쇠 = 유형원본 ? TRAINING_TYPE_I18N_KEY[유형원본.key] : undefined;
181
+ const trainingTypeName = 유형원본
182
+ ? (유형열쇠 ? t(`trainingType.${유형열쇠}.title`, { defaultValue: 유형원본.label }) : (유형원본.label ?? ''))
175
183
  : '';
176
184
  // 타입 라벨이 실제로 렌더되는지(타이틀과 같으면 숨김) — 레이아웃 여백 정리에 사용.
177
185
  const typeLabelRendered = (!!showTypeLabel && !!type?.label && type.label !== String(itemTitle)) ||
178
186
  (isSmall && !!trainingTypeName && trainingTypeName !== String(itemTitle));
179
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (typeof itemTitle === 'string' ? itemTitle : undefined), children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && btnHover && (_jsx(Badge, { type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(() => {
187
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isRow ? ' training-block-button--row' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (typeof itemTitle === 'string' ? itemTitle : undefined), children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && btnHover && (_jsx(Badge, { type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(() => {
180
188
  const countText = countLabelOverride !== undefined ? countLabelOverride : countLabel;
181
189
  // 정해진 문항수가 없으면(빈 값) 카운트 요소 자체를 그리지 않는다.
182
190
  return countText ? (_jsx("div", { className: `training-block-button__count ${isSmall ? 'caption-2' : 'caption-1'}`, children: countText })) : null;
183
- })(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), (isList || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
191
+ })(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), ((isList && !isRow) || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
184
192
  trainingData: topic,
185
193
  typeKey: type?.key ?? '',
186
194
  onClose: handleClose,
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { cx } from '../../utils/cx';
3
4
  import { useTranslation } from 'react-i18next';
@@ -24,7 +24,8 @@ export default function TrainingPagePopupTitle({ title, subtitle, desc, depth, i
24
24
  const subtitleText = typeof subtitle === 'string' ? subtitle.trim() : subtitle;
25
25
  const hasName = !!(titleText || subtitleText);
26
26
  const resolvedTitle = hasName ? (titleText || subtitleText) : t('common.emptyTrainingName', '훈련 이름이 비어 있습니다.');
27
- const rawDepth = depthText || subtitle;
27
+ // subtitle 을 이미 제목으로 썼으면(title 이 빔) depth 폴백에 다시 쓰지 않는다 — 같은 글이 두 줄로 찍혔다
28
+ const rawDepth = depthText || (titleText ? subtitle : undefined);
28
29
  // 모바일: " · " 또는 " / " 구분자가 있으면 마지막 세그먼트만 사용
29
30
  const resolvedDepth = (isMobile && typeof rawDepth === 'string' && (rawDepth.includes(' · ') || rawDepth.includes(' / ')))
30
31
  ? rawDepth.split(/\s[·/]\s/).pop()