@ihabdevteam/core 0.136.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 (76) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +20 -5
  8. package/dist/packages/core/src/components/Breadcrumb.js +1 -0
  9. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +3 -2
  10. package/dist/packages/core/src/components/Icon.js +4 -1
  11. package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
  12. package/dist/packages/core/src/components/LandingHeader.js +5 -1
  13. package/dist/packages/core/src/components/PageTitle.js +1 -0
  14. package/dist/packages/core/src/components/Pagination.js +1 -1
  15. package/dist/packages/core/src/components/Panel.css +3 -0
  16. package/dist/packages/core/src/components/Panel.d.ts +11 -1
  17. package/dist/packages/core/src/components/Panel.js +3 -3
  18. package/dist/packages/core/src/components/PanelRow.js +8 -1
  19. package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
  20. package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
  21. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
  22. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
  23. package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
  24. package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
  25. package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
  26. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
  27. package/dist/packages/core/src/components/Popup/Popup.css +10 -0
  28. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -0
  29. package/dist/packages/core/src/components/Popup/Popup.js +5 -4
  30. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
  31. package/dist/packages/core/src/components/ProgressIndicator.js +1 -0
  32. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
  33. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
  34. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
  35. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
  36. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
  37. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
  38. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
  39. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
  40. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
  41. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
  42. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
  43. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
  44. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
  45. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
  46. package/dist/packages/core/src/components/TextField.js +3 -1
  47. package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
  48. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -4
  49. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -0
  50. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +28 -1
  51. package/dist/packages/core/src/components/Training/TrainingStepCard.js +7 -1
  52. package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
  53. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
  54. package/dist/packages/core/src/components/Training/TrainingWordCard.js +21 -4
  55. package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
  56. package/dist/packages/core/src/components/Training/currentSound.js +13 -0
  57. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
  58. package/dist/packages/core/src/components/Training/useNoiseFade.js +4 -0
  59. package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
  60. package/dist/packages/core/src/components/WordTable.js +19 -5
  61. package/dist/packages/core/src/hooks/useFontScale.js +13 -4
  62. package/dist/packages/core/src/hooks/useIcon.js +3 -0
  63. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
  64. package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
  65. package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
  66. package/dist/packages/core/src/hooks/usePagedList.js +1 -0
  67. package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
  68. package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
  69. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
  70. package/dist/packages/core/src/i18n/locales/en.json +41 -4
  71. package/dist/packages/core/src/i18n/locales/ko.json +41 -4
  72. package/dist/packages/core/src/utils/errorMessage.js +7 -1
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +2 -2
  76. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { JSX } from 'react';
12
12
  import { KeyboardEvent as KeyboardEvent_2 } from 'react';
13
13
  import { MouseEvent as MouseEvent_2 } from 'react';
14
14
  import { default as React_2 } from 'react';
15
+ import * as React_3 from 'react';
15
16
  import { ReactNode } from 'react';
16
17
  import { ReactPortal } from 'react';
17
18
  import { Ref } from 'react';
@@ -1375,7 +1376,7 @@ export declare function copyText(text: string): Promise<boolean>;
1375
1376
  *
1376
1377
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1377
1378
  */
1378
- export declare const CORE_VERSION = "0.136.0";
1379
+ export declare const CORE_VERSION = "0.137.0";
1379
1380
 
1380
1381
  /** @public */
1381
1382
  declare const coreDefault: Record<string, unknown>;
@@ -2699,7 +2700,7 @@ export declare interface LandingBottomStickyProps {
2699
2700
  *
2700
2701
  * @public
2701
2702
  */
2702
- export declare function LandingHeader({ logo, logoAlt, logoHref, links, actions, className, containerClassName, renderLink, menuLabel, }: LandingHeaderProps): JSX.Element;
2703
+ export declare function LandingHeader({ logo, logoAlt, logoHref, links, actions, className, containerClassName, renderLink, menuLabel: menuLabelProp, }: LandingHeaderProps): JSX.Element;
2703
2704
 
2704
2705
  /**
2705
2706
  * 가운데 내비 링크 한 개.
@@ -2935,7 +2936,7 @@ export declare interface MediaQueryOverride {
2935
2936
  }
2936
2937
 
2937
2938
  /** @public */
2938
- export declare const MediaQueryOverrideContext: Context<MediaQueryOverride | null>;
2939
+ export declare const MediaQueryOverrideContext: React_3.Context<MediaQueryOverride | null>;
2939
2940
 
2940
2941
  /** @public */
2941
2942
  export declare function MenuTooltip({ columns, onClose, className, size }: {
@@ -3350,7 +3351,7 @@ export declare interface PairStats {
3350
3351
  total: number;
3351
3352
  }
3352
3353
 
3353
- export declare function Panel({ title, icon, disc, actions, size, minHeight, maxWidth, className, scrollable, useBorder, useBg, useRowBorder, labelWidth, form, loading, empty, children }: PanelProps): JSX.Element;
3354
+ export declare function Panel({ title, icon, disc, actions, size, minHeight, maxWidth, className, scrollable, scrollbarGutter, compactRows, useBorder, useBg, useRowBorder, labelWidth, form, loading, empty, children }: PanelProps): JSX.Element;
3354
3355
 
3355
3356
  /** @public */
3356
3357
  export declare type PanelElement = HTMLDivElement & {
@@ -3391,6 +3392,16 @@ declare interface PanelProps {
3391
3392
  maxWidth?: number | string;
3392
3393
  className?: string;
3393
3394
  scrollable?: boolean;
3395
+ /**
3396
+ * `scrollable` 본문의 스크롤바 자리. `'stable'` 이면 스크롤바가 없어도 자리를 비워 두어,
3397
+ * 내용이 늘어 스크롤바가 생길 때 폭이 튀지 않는다. 기본 `'auto'`(생길 때만 자리를 차지).
3398
+ */
3399
+ scrollbarGutter?: 'auto' | 'stable';
3400
+ /**
3401
+ * 행을 조금 낮춘다 — 최소 높이 46px·위아래 여백 3px. `size="small"` 에 겹쳐 쓰면 행 하나짜리 머리 없는 패널의
3402
+ * **테두리까지 바깥 높이가 48px** 이라 옆의 48px 단추와 줄이 맞는다(공유 링크 칸 등). 머리 높이·라벨 급은 그대로.
3403
+ */
3404
+ compactRows?: boolean;
3394
3405
  /** 외곽선 표시 여부. 기본 true */
3395
3406
  useBorder?: boolean;
3396
3407
  /** 배경(카드 흰 배경) 표시 여부. 기본 true. false면 투명 배경으로 페이지에 녹아든다 */
@@ -4175,6 +4186,11 @@ export declare interface PopupProps {
4175
4186
  * 그대로 걸리므로 창이 화면 밖으로 나가지 않는다. 넘치는 내용은 본문이 스스로 굴린다.
4176
4187
  */
4177
4188
  height?: number | string;
4189
+ /**
4190
+ * 본문이 패널 높이를 채운다 — 본문 자신은 굴리지 않고, 안쪽을 세로 flex(높이 100%)로 넘겨 **안의 한 칸만** 굴리게 한다.
4191
+ * `height` 로 창 높이를 박고 가운데 목록(`Panel scrollable` 등)만 굴러가야 하는 창에 쓴다.
4192
+ */
4193
+ fillBody?: boolean;
4178
4194
  /**
4179
4195
  * 이 값이 바뀌면 본문(.popup-content) 스크롤을 맨 위로 되돌린다.
4180
4196
  * 탭/모드 전환처럼 내부 콘텐츠가 통째로 바뀌는 팝업에서, 스크롤을 내린 채
@@ -7534,7 +7550,6 @@ export declare type TrainingStepListenProps = {
7534
7550
  showNoiseWaveForm?: boolean;
7535
7551
  };
7536
7552
 
7537
- /** @public */
7538
7553
  export declare function TrainingStepOrderList({ index, stepIndex, isLast, title, onDone, onChange, context, }: TrainingStepOrderListProps): JSX.Element;
7539
7554
 
7540
7555
  /** @public */
@@ -1,3 +1,4 @@
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 './Breadcrumb.css';
@@ -111,8 +111,9 @@ onComplete, trigger = 0, }) {
111
111
  // 움직임 최소화를 켠 사용자에게는 화면을 가로지르는 물리 애니메이션을 재생하지 않는다.
112
112
  // CSS의 전역 reduce 규칙(styles.css)은 requestAnimationFrame 기반인 이 효과를 막지 못한다.
113
113
  if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
114
- onCompleteRef.current?.();
115
- return;
114
+ // 곧바로 부르지 않고 한 틱 미룬다 — effect 안에서 바로 부르면 StrictMode 의 두 번 실행에서 onComplete 가 두 번
115
+ const id = setTimeout(() => onCompleteRef.current?.(), 0);
116
+ return () => clearTimeout(id);
116
117
  }
117
118
  // production: no debug logging
118
119
  const gravity = 1200; // px/s^2 (pulls downward) - reduced for longer arcs
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { createElement } from 'react';
3
4
  import useLucideIcon from '../hooks/useLucideIcon';
@@ -20,7 +21,9 @@ export default function Icon(props) {
20
21
  const { iconToUse, mode, svgText, blobUrl } = useIcon(iconStr);
21
22
  // ────────────────────────────────────────────────────────────────────────────
22
23
  const createWrapper = (children, extraProps = {}) => (_jsx("span", { className: wrapperClass, style: wrapperStyle, ...extraProps, children: children }));
23
- const createImage = (src, extraProps = {}) => (_jsx("img", { src: src, alt: typeof label === 'string' ? label : (label ? String(label) : ''), className: defaultImgClass, ...extraProps }));
24
+ const createImage = (src, extraProps = {}) => (
25
+ // 글자가 아닌 label(요소)은 alt 로 못 쓴다 — String() 하면 "[object Object]" 가 읽혔다
26
+ _jsx("img", { src: src, alt: typeof label === 'string' ? label : (typeof label === 'number' ? String(label) : ''), className: defaultImgClass, ...extraProps }));
24
27
  if (!icon)
25
28
  return null;
26
29
  if (typeof icon !== 'string')
@@ -64,4 +64,4 @@ export interface LandingHeaderProps {
64
64
  *
65
65
  * @public
66
66
  */
67
- export default function LandingHeader({ logo, logoAlt, logoHref, links, actions, className, containerClassName, renderLink, menuLabel, }: LandingHeaderProps): import("react").JSX.Element;
67
+ export default function LandingHeader({ logo, logoAlt, logoHref, links, actions, className, containerClassName, renderLink, menuLabel: menuLabelProp, }: LandingHeaderProps): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
4
5
  import { pushEsc } from '../hooks/escStack';
5
6
  import './LandingHeader.css';
6
7
  /**
@@ -18,7 +19,10 @@ import './LandingHeader.css';
18
19
  *
19
20
  * @public
20
21
  */
21
- export default function LandingHeader({ logo, logoAlt = '', logoHref, links = [], actions, className, containerClassName, renderLink, menuLabel = { open: '메뉴 열기', close: '메뉴 닫기' }, }) {
22
+ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = [], actions, className, containerClassName, renderLink, menuLabel: menuLabelProp, }) {
23
+ const { t } = useTranslation();
24
+ // 기본 이름표도 지금 언어로 — 박힌 '메뉴 열기' 가 영어 화면 햄버거 이름으로 읽혔다
25
+ const menuLabel = menuLabelProp ?? { open: t('header.menuOpen', '메뉴 열기'), close: t('header.menuClose', '메뉴 닫기') };
22
26
  const [open, setOpen] = useState(false);
23
27
  // 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
24
28
  // 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
@@ -1,3 +1,4 @@
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 './PageTitle.css';
@@ -19,5 +19,5 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
19
19
  const 마지막 = Math.max(1, totalPages);
20
20
  const handlePrev = () => onChange(Math.max(1, Math.min(마지막, page - 1)));
21
21
  const handleNext = () => onChange(Math.min(마지막, 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", children: [page, " / ", totalPages] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
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", children: [Math.min(Math.max(1, page), 마지막), " / ", 마지막] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
23
23
  }
@@ -104,6 +104,8 @@
104
104
  .panel--small .panel-row { min-height: 48px; }
105
105
  .panel--medium .panel-row { min-height: 56px; }
106
106
  .panel--large .panel-row { min-height: 64px; }
107
+ /* compactRows — 크기 규칙 **뒤에** 둬야 같은 특이도로 이긴다 */
108
+ .panel--compact-rows .panel-row { min-height: 46px; padding-top: 3px; padding-bottom: 3px; }
107
109
  /* 인접한 row 사이에만 구분선 (첫/끝 처리 불필요) */
108
110
  .panel-row + .panel-row {
109
111
  border-top: 1px solid var(--color-darkWhite);
@@ -156,6 +158,7 @@
156
158
  overflow-y: auto;
157
159
  overscroll-behavior-y: contain;
158
160
  }
161
+ .panel__body--gutter-stable { scrollbar-gutter: stable; }
159
162
 
160
163
  /* ── SectionRow variants ──────────────────────────────────────────────── */
161
164
  /* content: 단순 패딩 콘텐츠 영역 (min-height·flex 오버라이드) */
@@ -28,6 +28,16 @@ interface PanelProps {
28
28
  maxWidth?: number | string;
29
29
  className?: string;
30
30
  scrollable?: boolean;
31
+ /**
32
+ * `scrollable` 본문의 스크롤바 자리. `'stable'` 이면 스크롤바가 없어도 자리를 비워 두어,
33
+ * 내용이 늘어 스크롤바가 생길 때 폭이 튀지 않는다. 기본 `'auto'`(생길 때만 자리를 차지).
34
+ */
35
+ scrollbarGutter?: 'auto' | 'stable';
36
+ /**
37
+ * 행을 조금 낮춘다 — 최소 높이 46px·위아래 여백 3px. `size="small"` 에 겹쳐 쓰면 행 하나짜리 머리 없는 패널의
38
+ * **테두리까지 바깥 높이가 48px** 이라 옆의 48px 단추와 줄이 맞는다(공유 링크 칸 등). 머리 높이·라벨 급은 그대로.
39
+ */
40
+ compactRows?: boolean;
31
41
  /** 외곽선 표시 여부. 기본 true */
32
42
  useBorder?: boolean;
33
43
  /** 배경(카드 흰 배경) 표시 여부. 기본 true. false면 투명 배경으로 페이지에 녹아든다 */
@@ -44,7 +54,7 @@ interface PanelProps {
44
54
  empty?: ReactNode;
45
55
  children?: ReactNode;
46
56
  }
47
- export default function Panel({ title, icon, disc, actions, size, minHeight, maxWidth, className, scrollable, useBorder, useBg, useRowBorder, labelWidth, form, loading, empty, children }: PanelProps): import("react").JSX.Element;
57
+ export default function Panel({ title, icon, disc, actions, size, minHeight, maxWidth, className, scrollable, scrollbarGutter, compactRows, useBorder, useBg, useRowBorder, labelWidth, form, loading, empty, children }: PanelProps): import("react").JSX.Element;
48
58
  /** @public */
49
59
  export declare function PanelGrid({ cols, children }: {
50
60
  cols?: number;
@@ -5,10 +5,10 @@ import { PanelRow } from './PanelRow';
5
5
  import { Children } from 'react';
6
6
  import Icon from './Icon';
7
7
  import './Panel.css';
8
- export default function Panel({ title, icon, disc, actions, size = 'medium', minHeight, maxWidth, className, scrollable, useBorder = true, useBg = true, useRowBorder = true, labelWidth, form = false, loading, empty, children }) {
8
+ export default function Panel({ title, icon, disc, actions, size = 'medium', minHeight, maxWidth, className, scrollable, scrollbarGutter = 'auto', compactRows = false, useBorder = true, useBg = true, useRowBorder = true, labelWidth, form = false, loading, empty, children }) {
9
9
  const cls = cx('panel', `panel--${size}`, form ? 'panel--form' : '',
10
10
  /* minHeight 패널은 마지막 행 아래 빈 공간이 생길 수 있으므로 행 하단 구분선 사용 */
11
- minHeight !== undefined ? 'panel--fill' : '', !useBorder ? 'panel--no-border' : '', !useBg ? 'panel--no-bg' : '', !useRowBorder ? 'panel--no-row-border' : '', className);
11
+ minHeight !== undefined ? 'panel--fill' : '', !useBorder ? 'panel--no-border' : '', !useBg ? 'panel--no-bg' : '', !useRowBorder ? 'panel--no-row-border' : '', compactRows ? 'panel--compact-rows' : '', className);
12
12
  const style = {};
13
13
  if (minHeight !== undefined)
14
14
  style.minHeight = minHeight;
@@ -18,7 +18,7 @@ export default function Panel({ title, icon, disc, actions, size = 'medium', min
18
18
  style['--panel-label-width'] = typeof labelWidth === 'number' ? `${labelWidth}px` : labelWidth;
19
19
  return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(title != null || 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 != null && (_jsx("h2", { 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" })
20
20
  /* 비었는지는 React 가 그리는 것으로 센다 — `{조건 && 행들}` 이 false 면 children 이 false 라 빈 상태 문구가 안 떴다 */
21
- ) : empty != null && Children.toArray(children).length === 0 ? (_jsx(PanelRow, { variant: "empty", children: empty })) : scrollable ? (_jsx("div", { className: "panel__body--scroll", children: children })) : children] }));
21
+ ) : 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] }));
22
22
  }
23
23
  // ── PanelGrid ──────────────────────────────────────────────────────────────
24
24
  /** @public */
@@ -9,6 +9,13 @@ import Dropdown from './Dropdown';
9
9
  import Button from './Button';
10
10
  import Badge from './Badge';
11
11
  import { deviceKindVariant } from '../utils/deviceKind';
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
+ }
12
19
  import TextField from './TextField';
13
20
  import Loader from './Loader';
14
21
  import './Panel.css';
@@ -143,6 +150,6 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
143
150
  /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
144
151
  이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
145
152
  (잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
146
- _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x", ariaLabel: 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: d.device }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
153
+ _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x", ariaLabel: 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: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
147
154
  }) }));
148
155
  }
@@ -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 './PassCardBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PassCardBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import { IHAB_OUTLINE_PATH } from './ihabOutlinePath';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import { IHAB_OUTLINE_PATH } from './ihabOutlinePath';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PassCardBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PassCardBg.css';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useParentBgTheme } from '../../hooks/useParentBgTheme';
3
4
  import './PassCardBg.css';
@@ -8,6 +8,13 @@ import Button from '../Button';
8
8
  import Checkbox from '../Checkbox';
9
9
  import Badge from '../Badge';
10
10
  import { deviceKindVariant } from '../../utils/deviceKind';
11
+ /* 기기 종류 배지 이름은 지금 언어로 — 값은 저장된 한국어(보청기·인공와우)라 그대로 보이면 영어 화면에도 한국어였다.
12
+ 색(deviceKindVariant)은 저장값으로 가른다. 모르는 값은 그대로 */
13
+ const 기기종류열쇠 = { '보청기': 'hearingAid', '인공와우': 'cochlearImplant' };
14
+ function 기기종류이름(device, t) {
15
+ const k = 기기종류열쇠[device];
16
+ return k ? t(`device.type.${k}`, { defaultValue: device }) : device;
17
+ }
11
18
  import { useTablePaging, useTableSort } from '../../hooks/useTablePaging';
12
19
  import './DeviceListPopup.css';
13
20
  /**
@@ -72,7 +79,7 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
72
79
  /* 종류는 위 패널의 종류 배지와 같은 모양으로 — 같은 값을 두 곳에서 다르게
73
80
  그리면 같은 것인 줄 모른다. 값이 비면 배지도 세우지 않는다(빈 배지는
74
81
  '무언가 있다'는 거짓 신호다). */
75
- { content: row.device ? _jsx(Badge, { size: "small", variant: deviceKindVariant(row.device), fill: "soft", label: row.device }) : _jsx("span", {}) },
82
+ { content: row.device ? _jsx(Badge, { size: "small", variant: deviceKindVariant(row.device), fill: "soft", label: 기기종류이름(row.device, t) }) : _jsx("span", {}) },
76
83
  { content: _jsx("span", { children: row.brand }) },
77
84
  { content: _jsx("span", { children: row.fitType || '-' }) },
78
85
  { content: _jsx("span", { children: row.productName || '-' }) },
@@ -35,6 +35,16 @@
35
35
  .popup-panel--medium { max-width: 680px; }
36
36
  .popup-panel--large { max-width: 1080px; }
37
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
+ }
38
48
  /* fit — 내용 폭. 최소·최대 모두 화면 폭 - 40px 을 넘지 않는다(좁은 화면에서 480px 로 잘리지 않게) */
39
49
  .popup-panel--fit {
40
50
  width: fit-content;
@@ -89,6 +89,11 @@ export interface PopupProps {
89
89
  * 그대로 걸리므로 창이 화면 밖으로 나가지 않는다. 넘치는 내용은 본문이 스스로 굴린다.
90
90
  */
91
91
  height?: number | string;
92
+ /**
93
+ * 본문이 패널 높이를 채운다 — 본문 자신은 굴리지 않고, 안쪽을 세로 flex(높이 100%)로 넘겨 **안의 한 칸만** 굴리게 한다.
94
+ * `height` 로 창 높이를 박고 가운데 목록(`Panel scrollable` 등)만 굴러가야 하는 창에 쓴다.
95
+ */
96
+ fillBody?: boolean;
92
97
  /**
93
98
  * 이 값이 바뀌면 본문(.popup-content) 스크롤을 맨 위로 되돌린다.
94
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, layer = 'modal', 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(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
@@ -159,9 +159,10 @@ export default function Popup(props) {
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);
@@ -1,3 +1,4 @@
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';
@@ -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';
@@ -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';
@@ -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
  }
@@ -172,8 +175,11 @@ export default function TrainingBlockButton(props) {
172
175
  const isList = size === 'list' || isRow;
173
176
  const isCompact = size === 'compact';
174
177
  const isSmall = size === 'small';
175
- const trainingTypeName = isSmall && type?.key
176
- ? (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 ?? ''))
177
183
  : '';
178
184
  // 타입 라벨이 실제로 렌더되는지(타이틀과 같으면 숨김) — 레이아웃 여백 정리에 사용.
179
185
  const typeLabelRendered = (!!showTypeLabel && !!type?.label && type.label !== String(itemTitle)) ||
@@ -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';