@ihabdevteam/core 0.133.0 → 0.134.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 (42) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +2 -2
  6. package/dist/ihabdevteam-core.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +8 -2
  8. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  9. package/dist/packages/core/src/components/Dialog.js +32 -9
  10. package/dist/packages/core/src/components/Header.css +2 -0
  11. package/dist/packages/core/src/components/Header.js +4 -1
  12. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  13. package/dist/packages/core/src/components/Pagination.js +5 -2
  14. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  15. package/dist/packages/core/src/components/Panel.js +4 -1
  16. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  17. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  18. package/dist/packages/core/src/components/PaymentPlan.js +5 -3
  19. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  20. package/dist/packages/core/src/components/SideNav.js +1 -1
  21. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  22. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
  23. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  24. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  25. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  26. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
  27. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  28. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  29. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  30. package/dist/packages/core/src/i18n/locales/en.json +11 -0
  31. package/dist/packages/core/src/i18n/locales/ko.json +11 -0
  32. package/dist/packages/core/src/utils/format.js +17 -1
  33. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  34. package/dist/packages/core/src/utils/pressable.js +4 -0
  35. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  36. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  37. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  38. package/dist/packages/core/src/utils/wordTrainingRuntime.js +27 -13
  39. package/dist/packages/core/src/version.d.ts +1 -1
  40. package/dist/packages/core/src/version.js +1 -1
  41. package/dist/utils.cjs.js +2 -2
  42. package/package.json +1 -1
@@ -1308,7 +1308,7 @@ export declare interface ContrastFilterPanelProps {
1308
1308
  }
1309
1309
 
1310
1310
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
1311
- export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
1311
+ export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
1312
1312
 
1313
1313
  /**
1314
1314
  * 텍스트를 클립보드에 복사. 성공 여부를 boolean으로 반환.
@@ -1369,7 +1369,7 @@ export declare function copyText(text: string): Promise<boolean>;
1369
1369
  *
1370
1370
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1371
1371
  */
1372
- export declare const CORE_VERSION = "0.133.0";
1372
+ export declare const CORE_VERSION = "0.134.0";
1373
1373
 
1374
1374
  /** @public */
1375
1375
  declare const coreDefault: Record<string, unknown>;
@@ -3794,6 +3794,12 @@ export declare interface PaymentPlanTier {
3794
3794
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
3795
3795
  */
3796
3796
  openPromo?: boolean;
3797
+ /**
3798
+ * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
3799
+ * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
3800
+ * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
3801
+ */
3802
+ onceOnly?: boolean;
3797
3803
  [k: string]: unknown;
3798
3804
  }
3799
3805
 
@@ -1,9 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from 'react-i18next';
2
3
  import './Breadcrumb.css';
3
4
  import Icon from './Icon';
4
5
  /** @public */
5
6
  export default function Breadcrumb({ items, className = '' }) {
6
- return (_jsx("nav", { className: `breadcrumb ${className}`.trim(), "aria-label": "breadcrumb", children: _jsx("ol", { className: "breadcrumb__list", children: items.map((item, index) => {
7
+ const { t } = useTranslation();
8
+ return (
9
+ /* 영어로 박혀 있어 한국어 화면에서도 낭독기가 "breadcrumb" 이라고 읽었다 — 사전을 탄다 */
10
+ _jsx("nav", { className: `breadcrumb ${className}`.trim(), "aria-label": t('breadcrumb.ariaLabel', '이동 경로'), children: _jsx("ol", { className: "breadcrumb__list", children: items.map((item, index) => {
7
11
  const isLast = index === items.length - 1;
8
12
  return (_jsxs("li", { className: "breadcrumb__item", children: [isLast ? (_jsx("span", { className: "breadcrumb__current body-3", "aria-current": "page", children: item.label })) : (_jsx("button", { type: "button", className: "breadcrumb__link body-3", onClick: item.onClick, children: item.label })), !isLast && (_jsx(Icon, { icon: "chevron-right", size: 14, className: "breadcrumb__separator" }))] }, index));
9
13
  }) }) }));
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { cx } from '../utils/cx';
7
+ import { lockBodyScroll } from '../hooks/bodyScrollLock';
7
8
  import { pushEsc } from '../hooks/escStack';
8
9
  import { useFocusTrap } from '../hooks/useFocusTrap';
9
10
  import './Dialog.css';
@@ -39,12 +40,18 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
39
40
  if (!open)
40
41
  return;
41
42
  const 돌아갈곳 = document.activeElement;
42
- const { overflow } = document.body.style;
43
- document.body.style.overflow = 'hidden';
44
- surfaceRef.current?.focus();
43
+ /* 스크롤 잠금은 **공유 카운터**(Popup·TrainingPagePopup 과 같은 것)로 — 전에는 body.style.overflow 를 제 손으로
44
+ 기억했다 되돌려, Dialog 위에 Popup 을 열고 Dialog 를 먼저 닫으면 Popup 이 떠 있는데 스크롤이 풀리고, 이어 Popup 을
45
+ 닫으면 overflow:hidden 으로 영영 굳었다. */
46
+ const 풀기 = lockBodyScroll();
47
+ const 판 = surfaceRef.current;
48
+ 판?.focus();
45
49
  return () => {
46
- document.body.style.overflow = overflow;
47
- 돌아갈곳?.focus?.();
50
+ 풀기();
51
+ // 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 되돌린다 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
52
+ const 지금 = document.activeElement;
53
+ if (!지금 || 지금 === document.body || (판 && 판.contains(지금)))
54
+ 돌아갈곳?.focus?.();
48
55
  };
49
56
  }, [open]);
50
57
  /* ESC 는 **공유 스택**에 맡긴다 — 예전에는 window 에서 직접 들었다.
@@ -53,11 +60,21 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
53
60
  **아래 팝업이 닫히고 이 확인창만 남는** 꼴이다 — 닫으려던 것은 그대로 있고
54
61
  뒤에 있던 화면이 사라진다.
55
62
  스택에 올리면 맨 위 하나만 닫힌다(그것이 이 창이다). */
63
+ /* 스택에는 **열릴 때 한 번** 올린다 — handleClose 가 바뀔 때마다(인라인 onClose) 다시 올리면 맨 위로 끼어들어, 위에
64
+ 팝업이 떠 있어도 Esc 가 이 창을 닫았다. closeOnEscape={false} 라도 올려서 Esc 를 **삼킨다** — 안 올리면 이 창
65
+ 아래에 가려진 팝업이 닫혔다(모달은 제 아래를 지킨다). */
66
+ const closeRef = useRef(handleClose);
67
+ const escRef = useRef(closeOnEscape);
68
+ useEffect(() => { closeRef.current = handleClose; escRef.current = closeOnEscape; });
56
69
  useEffect(() => {
57
- if (!open || !closeOnEscape)
70
+ if (!open)
58
71
  return;
59
- return pushEsc(handleClose);
60
- }, [open, closeOnEscape, handleClose]);
72
+ return pushEsc(() => { if (escRef.current)
73
+ closeRef.current(); });
74
+ }, [open]);
75
+ /* 바탕 누르기 — 누른 곳과 뗀 곳이 **둘 다 바탕일 때만** 닫는다(Popup 0.128.0 과 같다). 판 안에서 글자를 긁어
76
+ 고르다 바깥에서 놓으면 click 이 공통 조상(바탕)에 떨어져 창이 닫혔다. */
77
+ const 바탕에서누름 = useRef(false);
61
78
  /* Tab 을 이 판 안에 가둔다 — `aria-modal="true"` 는 **낭독기에게만** 통한다.
62
79
  눈으로 보며 Tab 을 누르는 사람은 그 표시를 못 보므로, 가둠이 없으면 마지막
63
80
  단추 다음에서 **뒤 화면**으로 빠져나간다. 실제로 그랬다: 놀이터
@@ -67,5 +84,11 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
67
84
  useFocusTrap(surfaceRef, open);
68
85
  if (!open || typeof document === 'undefined')
69
86
  return null;
70
- return createPortal(_jsx("div", { className: cx('dialog', className), onClick: closeOnBackdrop ? handleClose : undefined, children: _jsxs("div", { ref: surfaceRef, role: "dialog", "aria-modal": "true", "aria-label": title ? undefined : ariaLabel, "aria-labelledby": title ? titleId : undefined, tabIndex: -1, className: cx('dialog__surface', `dialog__surface--${size}`, contentClassName), onClick: (e) => e.stopPropagation(), children: [title ? (_jsx("div", { className: "dialog__header", children: _jsx("h2", { id: titleId, className: "dialog__title h4b", children: title }) })) : null, showClose ? (_jsx("button", { type: "button", className: "dialog__close", "aria-label": t('common.close', '닫기'), onClick: handleClose, children: _jsx(CloseIcon, {}) })) : null, _jsx("div", { className: "dialog__body", children: children })] }) }), document.body);
87
+ return createPortal(_jsx("div", { className: cx('dialog', className), onPointerDown: closeOnBackdrop ? (e) => { 바탕에서누름.current = e.target === e.currentTarget; } : undefined, onMouseDown: (e) => { if (e.target === e.currentTarget)
88
+ e.preventDefault(); }, onClick: closeOnBackdrop ? (e) => {
89
+ const 눌렀나 = 바탕에서누름.current;
90
+ 바탕에서누름.current = false;
91
+ if (눌렀나 && e.target === e.currentTarget)
92
+ handleClose();
93
+ } : undefined, children: _jsxs("div", { ref: surfaceRef, role: "dialog", "aria-modal": "true", "aria-label": title ? undefined : ariaLabel, "aria-labelledby": title ? titleId : undefined, tabIndex: -1, className: cx('dialog__surface', `dialog__surface--${size}`, contentClassName), onClick: (e) => e.stopPropagation(), children: [title ? (_jsx("div", { className: "dialog__header", children: _jsx("h2", { id: titleId, className: "dialog__title h4b", children: title }) })) : null, showClose ? (_jsx("button", { type: "button", className: "dialog__close", "aria-label": t('common.close', '닫기'), onClick: handleClose, children: _jsx(CloseIcon, {}) })) : null, _jsx("div", { className: "dialog__body", children: children })] }) }), document.body);
71
94
  }
@@ -21,6 +21,8 @@
21
21
  display: none;
22
22
  background: transparent;
23
23
  border: none;
24
+ /* 읽지 않은 알림 배지(top-right)의 기준 — SideNav 알림 단추(.side-nav__notification-wrap)와 같다 */
25
+ position: relative;
24
26
  }
25
27
 
26
28
  .header__bottom {
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
  import { useMediaQuery } from '../hooks';
6
6
  import BottomNav from './BottomNav';
7
7
  import Button from './Button';
8
+ import Badge from './Badge';
8
9
  import MenuTooltip from './Tooltip/MenuTooltip';
9
10
  import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
10
11
  import { createPortal } from 'react-dom';
@@ -112,7 +113,9 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
112
113
  onClick: () => { it.onClick?.(); closeUtilMenu(); },
113
114
  })),
114
115
  }], [bottomMoreItems, activeKey, closeUtilMenu]);
115
- return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
116
+ return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
117
+ ? t('nav.badgeLabel', { label: t('header.menuOpen', '메뉴 열기'), count: unreadCount, defaultValue: '{{label}}, {{count}}개' })
118
+ : t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
116
119
  }
117
120
  /**
118
121
  * `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId, useState } from 'react';
3
+ import { useEffect, useId, useState } from 'react';
4
+ import { pushEsc } from '../hooks/escStack';
4
5
  import './LandingHeader.css';
5
6
  /**
6
7
  * 랜딩/마케팅 페이지용 상단 고정 헤더.
@@ -22,6 +23,22 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
22
23
  // 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
23
24
  // 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
24
25
  const drawerId = useId();
26
+ /* 서랍이 열려 있으면 Esc 로 닫는다(공유 ESC 스택 — 맨 위 하나만). 넓은 화면(961px~, 서랍이 CSS 로 숨는 폭)으로 가면
27
+ 닫는다 — 안 닫으면 open 이 남아 좁은 폭으로 돌아오는 순간 서랍이 다시 펼쳐졌다. */
28
+ useEffect(() => {
29
+ if (!open)
30
+ return;
31
+ const 풀기 = pushEsc(() => setOpen(false));
32
+ const mq = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(min-width: 961px)') : null;
33
+ const 넓어짐 = (e) => { if (e.matches)
34
+ setOpen(false); };
35
+ if (mq) {
36
+ if (mq.matches)
37
+ setOpen(false);
38
+ mq.addEventListener?.('change', 넓어짐);
39
+ }
40
+ return () => { 풀기(); mq?.removeEventListener?.('change', 넓어짐); };
41
+ }, [open]);
25
42
  const innerClass = containerClassName
26
43
  ? `${containerClassName} landing-header__inner`
27
44
  : 'landing-header__inner';
@@ -14,7 +14,10 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
14
14
  const { t } = useTranslation();
15
15
  const resolvedPrev = prevLabel ?? t('common.prevPage', '이전 페이지');
16
16
  const resolvedNext = nextLabel ?? t('common.nextPage', '다음 페이지');
17
- const handlePrev = () => onChange(Math.max(1, page - 1));
18
- const handleNext = () => onChange(Math.min(totalPages, page + 1));
17
+ /* 범위 밖 쪽(page > totalPages — 결과가 줄어든 직후)에서도 '이전' 이 범위 안으로 돌아오게 먼저 조인다.
18
+ 전에는 page-1 이 여전히 totalPages 보다 커 눌러도 범위 밖에 머물렀다. */
19
+ const 마지막 = Math.max(1, totalPages);
20
+ const handlePrev = () => onChange(Math.max(1, Math.min(마지막, page - 1)));
21
+ const handleNext = () => onChange(Math.min(마지막, page + 1));
19
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 })] }));
20
23
  }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
2
  import './Panel.css';
3
3
  type PanelSize = 'small' | 'medium' | 'large';
4
4
  interface PanelProps {
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cx } from '../utils/cx';
4
4
  import { PanelRow } from './PanelRow';
5
+ import { Children } from 'react';
5
6
  import Icon from './Icon';
6
7
  import './Panel.css';
7
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 }) {
@@ -15,7 +16,9 @@ export default function Panel({ title, icon, disc, actions, size = 'medium', min
15
16
  style.maxWidth = typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
16
17
  if (labelWidth !== undefined)
17
18
  style['--panel-label-width'] = typeof labelWidth === 'number' ? `${labelWidth}px` : labelWidth;
18
- 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" })) : empty != null && (children == null || (Array.isArray(children) && children.length === 0)) ? (_jsx(PanelRow, { variant: "empty", children: empty })) : scrollable ? (_jsx("div", { className: "panel__body--scroll", children: children })) : children] }));
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
+ /* 비었는지는 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] }));
19
22
  }
20
23
  // ── PanelGrid ──────────────────────────────────────────────────────────────
21
24
  /** @public */
@@ -24,6 +24,12 @@ function derivePlan(p) {
24
24
  const nm = name.match(/(\d+\s*(?:년|개월|주|일))\s*(?:이용권)?\s*$/);
25
25
  if (nm)
26
26
  return nm[1].replace(/\s+/g, '');
27
+ /* 영어 이름('Open Special 30-Day Plan'·'1-Year Plan')에서도 일·년 기간을 뽑는다 — 한국어 꼴만 봐서 영어 화면(말귀 랜딩
28
+ 요금 카드는 code 를 안 넘긴다)에서는 이름 전체가 큰 글씨로 찍히고 뒤에 등급 이름이 또 붙었다. 돌려주는 토큰은 위와 같은
29
+ '30일'·'1년' 꼴이다 — PassCard 가 숫자와 단위로 쪼개 단위를 passCard.unitDay·unitYear 로 번역한다. */
30
+ const en = name.match(/(\d+)\s*-?\s*(day|year)s?\b/i);
31
+ if (en)
32
+ return en[2].toLowerCase() === 'year' ? `${en[1]}년` : `${en[1]}일`;
27
33
  return name || '이용권';
28
34
  }
29
35
  /**
@@ -20,6 +20,12 @@ export interface PaymentPlanTier {
20
20
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
21
21
  */
22
22
  openPromo?: boolean;
23
+ /**
24
+ * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
25
+ * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
26
+ * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
27
+ */
28
+ onceOnly?: boolean;
23
29
  [k: string]: unknown;
24
30
  }
25
31
  /** @public */
@@ -43,7 +43,8 @@ export default function PaymentPlan(props) {
43
43
  const { plan, onSelect, period = "monthly", ownedCodes } = props || {};
44
44
  const owned = (code) => !!code && !!ownedCodes?.includes(code);
45
45
  // 이 플랜의 오픈특가 tier를 이미 보유(구매)했는지 — 그러면 기본 tier를 카드로 승격
46
- const openSoldOut = (plan.tiers || []).some(tr => /오픈\s*특가/.test(tr.label ?? '') && owned(tr.code));
46
+ const isOnceOnlyTier = (tr) => tr.onceOnly ?? /오픈\s*특가/.test(tr.label ?? '');
47
+ const openSoldOut = (plan.tiers || []).some(tr => isOnceOnlyTier(tr) && owned(tr.code));
47
48
  // 할인 tier 가 정가를 취소선으로 이미 보여 주면, 같은 값을 가진 정가 행은 같은 상품을 두 번
48
49
  // 늘어놓는 셈이 된다(30일 이용권 ₩10,000 행 + 30일 ₩10,000→₩5,000 카드). 그 행은 숨긴다.
49
50
  const coveredByDiscount = (tier) => {
@@ -61,7 +62,8 @@ export default function PaymentPlan(props) {
61
62
  const tierPriceOf = (tier) => (tier.prices ? tier.prices[period] : tier.price);
62
63
  const isFree = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
63
64
  const price = tierPriceOf(tier);
64
- return price === "무료" || price === freePrice || price === "0원";
65
+ // 숫자가 모두 0 인 가격도 무료다 — '₩0'·'$0.00' 처럼 로케일마다 표기가 달라 '무료'·'0원' 글자만 보면 영어 화면에서 놓쳤다
66
+ return price === "무료" || price === freePrice || price === "0원" || (typeof price === 'string' && /\d/.test(price) && !/[1-9]/.test(price));
65
67
  });
66
68
  // 맞춤 견적(문의) 플랜 — 가격이 '문의'(ko) 또는 로케일 견적 문구
67
69
  const isInquiry = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
@@ -85,7 +87,7 @@ export default function PaymentPlan(props) {
85
87
  읽는** 것이라 한국어 그대로여야 한다. */
86
88
  const rawLabel = tier.label || 기본이용권;
87
89
  // 라벨의 '오픈 특가'는 계정당 1회 제한의 표시다 — 숨김·소진 판정에만 쓴다.
88
- const isOnceOnly = /오픈\s*특가/.test(rawLabel);
90
+ const isOnceOnly = isOnceOnlyTier(tier);
89
91
  // 배지 문구는 별도 — 같은 프로모션이되 1회 제한은 없는 tier(1년권)도 함께 얹는다.
90
92
  const showOpenPromo = isOnceOnly || tier.openPromo === true;
91
93
  const planText = rawLabel.replace(/^오픈\s*특가\s*/, '') || 기본이용권;
@@ -65,7 +65,7 @@ export default function PromoBanner({ title, description, actionLabel, onAction,
65
65
  */
66
66
  const cls = cx('promo-banner', `promo-banner--${size}`, className);
67
67
  const Bg = PROMO_BANNER_BG_COMPONENTS[resolvedBg];
68
- return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
68
+ return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { htmlType: "button", size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
69
69
  ? { type: 'dot', icon: 'arrow-right' }
70
70
  : { rightIcon: 'arrow-right', label: actionLabel, minWidth: 140 }) }))] }));
71
71
  }
@@ -51,7 +51,7 @@ function SideNavButton(props) {
51
51
  }
52
52
  onClick?.(key, e.currentTarget, fromPointer);
53
53
  }, [isDisabled, key, onClick]);
54
- return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label": "loading" }))] }));
54
+ return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
55
55
  }
56
56
  /** @public */
57
57
  export default function SideNav(props) {
@@ -6,6 +6,7 @@
6
6
  'use client';
7
7
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { useMemo } from 'react';
9
+ import { useTranslation } from 'react-i18next';
9
10
  import Check from 'lucide-react/dist/esm/icons/check.mjs';
10
11
  import Volume2 from 'lucide-react/dist/esm/icons/volume-2.mjs';
11
12
  import User from 'lucide-react/dist/esm/icons/user.mjs';
@@ -15,6 +16,7 @@ import './SoundCompass.css';
15
16
  const lerp = (a, b, t) => a + (b - a) * t;
16
17
  /** @public */
17
18
  export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
19
+ const { t: 번역 } = useTranslation();
18
20
  const t = Math.max(0, Math.min(1, viewT));
19
21
  const CENTER = size / 2;
20
22
  // ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
@@ -119,6 +121,13 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
119
121
  }
120
122
  // ── 스피커 시각 상태를 props에서 파생 ──────────────────────────────────
121
123
  const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
124
+ const stateLabel = (state) => ({
125
+ idle: '',
126
+ active: 번역('soundCompass.state.active', '소리 나는 중'),
127
+ played: 번역('soundCompass.state.played', '들음'),
128
+ correct: 번역('soundCompass.state.correct', '정답'),
129
+ wrong: 번역('soundCompass.state.wrong', '오답'),
130
+ })[state];
122
131
  const getSpeakerState = (sp) => {
123
132
  if (activeSpeakerId === sp.id)
124
133
  return 'active';
@@ -209,6 +218,6 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
209
218
  ...(pulseScale !== undefined
210
219
  ? { ['--sc-pulse-scale']: String(pulseScale) }
211
220
  : null),
212
- } })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": sp.label, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
221
+ } })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": stateLabel(state) ? `${sp.label}, ${stateLabel(state)}` : sp.label, "aria-pressed": selectedId != null ? sp.id === selectedId : undefined, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
213
222
  })] }) }));
214
223
  }
@@ -87,6 +87,11 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
87
87
  return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
88
88
  ? 'chevron-right'
89
89
  : item.badgeCount
90
- ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-label": `${item.badgeCount}` })
91
- : undefined, label: item.label, ariaLabel: item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
90
+ ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-hidden": true })
91
+ : undefined, label: item.label,
92
+ /* 개수는 이름에 붙인다 — ariaLabel 이 글자뿐이라 배지 수가 이름에서 빠져 "알림" 으로만 읽혔다
93
+ (SideNav·BottomNav 와 같은 nav.badgeLabel "알림, 5개"). */
94
+ ariaLabel: item.label && item.badgeCount && item.badgeCount > 0
95
+ ? t('nav.badgeLabel', { label: item.label, count: item.badgeCount, defaultValue: '{{label}}, {{count}}개' })
96
+ : item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
92
97
  }
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
70
70
  fontSize: `${fontSize}px`,
71
71
  lineHeight: `${lineHeight}px`,
72
72
  }), [fontSize, lineHeight]);
73
- return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
73
+ return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
74
74
  }
@@ -24,6 +24,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
24
24
  // 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
25
25
  const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
26
26
  const noiseTypeLoadedRef = useRef(null);
27
+ /* 바꿔 끼울 소음 파일 — 종류를 바꾸면 여기에 두고, 페이드아웃이 끝나거나 **다음 페이드인이 시작될 때** 끼운다.
28
+ 전에는 페이드아웃의 완료 콜백에만 걸어 두어, 150ms 안에 다른 페이드(다음 트랙의 fadeInNoise·문장 종료의
29
+ fadeOutNoise·탭 숨김)가 끼면 콜백이 사라졌다 — 종류 표시·파형은 새 것인데 **소리는 이전 파일**이었다. */
30
+ const pendingNoiseUrlRef = useRef(null);
27
31
  // volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
28
32
  const noiseSettingsRef = useRef(null);
29
33
  const noiseFadeRafRef = useRef(null);
@@ -43,7 +47,19 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
43
47
  }, []);
44
48
  const fadeInNoise = useCallback((targetVolume, durationMs = 250) => {
45
49
  const noiseEl = noiseAudioRef.current;
46
- if (!noiseEl || !noiseEl.src)
50
+ if (!noiseEl)
51
+ return;
52
+ const pending = pendingNoiseUrlRef.current;
53
+ if (pending) {
54
+ pendingNoiseUrlRef.current = null;
55
+ try {
56
+ noiseEl.pause();
57
+ }
58
+ catch (e) { }
59
+ noiseEl.src = pending;
60
+ noiseEl.load();
61
+ }
62
+ if (!noiseEl.src)
47
63
  return;
48
64
  noiseFadeTokenRef.current += 1;
49
65
  cancelNoiseFade();
@@ -235,7 +251,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
235
251
  if (url) {
236
252
  noiseTypeLoadedRef.current = ns.type;
237
253
  setActiveNoiseUrl(url); // waveform이 실제 재생 파일을 그리도록
254
+ pendingNoiseUrlRef.current = url;
238
255
  fadeOutNoise(150, () => {
256
+ // 그 사이 다음 페이드인이 이미 끼워 넣었으면(pending 이 비었으면) 다시 끼우지 않는다
257
+ if (pendingNoiseUrlRef.current !== url)
258
+ return;
259
+ pendingNoiseUrlRef.current = null;
239
260
  noiseEl.src = url;
240
261
  noiseEl.load();
241
262
  const mainAudio = audioRef.current;
@@ -13,9 +13,14 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
13
13
  const ttsStartRef = useRef(null);
14
14
  const ttsEstimatedDurationRef = useRef(0);
15
15
  const ttsRafRef = useRef(null);
16
+ /* 지금 말하는 발화의 번호. 정지(cancel)·다시 시작·언마운트에서 올린다 — Chrome·Safari 는 cancel() 한 발화에도
17
+ end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
18
+ 언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
19
+ const ttsTokenRef = useRef(0);
16
20
  useEffect(() => {
17
21
  // cleanup TTS resources on unmount
18
22
  return () => {
23
+ ttsTokenRef.current++;
19
24
  try {
20
25
  if (ttsRafRef.current) {
21
26
  cancelAnimationFrame(ttsRafRef.current);
@@ -52,6 +57,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
52
57
  return;
53
58
  }
54
59
  const synth = window.speechSynthesis;
60
+ const myToken = ++ttsTokenRef.current;
55
61
  try {
56
62
  try {
57
63
  synth.cancel();
@@ -99,6 +105,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
99
105
  catch (e) { }
100
106
  };
101
107
  utt.onend = () => {
108
+ if (myToken !== ttsTokenRef.current)
109
+ return;
102
110
  try {
103
111
  if (ttsRafRef.current) {
104
112
  cancelAnimationFrame(ttsRafRef.current);
@@ -149,6 +157,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
149
157
  }
150
158
  }, [ttsText, playbackRate, mainVolume, fadeInNoise, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, noiseSettingsRef, onDoneRef, handleEndRef]);
151
159
  const stopTTS = useCallback(() => {
160
+ ttsTokenRef.current++;
152
161
  try {
153
162
  if (window.speechSynthesis) {
154
163
  try {
@@ -105,9 +105,10 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
105
105
  }
106
106
  return [...byLabel.entries()].map(([label, its]) => ({ label, items: its }));
107
107
  }, [groups, orderedGroupItems, pageClamped, pageSize]);
108
- // 정렬/페이지크기 변경·결과 개수 변동 시 1페이지로.
108
+ // 정렬/페이지크기 변경·결과 개수 변동·**검색어 변경** 시 1페이지로. 검색어를 안 보면 결과 수가 같은 다른 검색에서
109
+ // 보던 쪽 번호(3쪽)가 남아 새 결과의 3쪽이 떴다.
109
110
  /* eslint-disable react-hooks/set-state-in-effect */
110
- useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount]);
111
+ useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount, searchQuery]);
111
112
  /* eslint-enable react-hooks/set-state-in-effect */
112
113
  // 결과가 있으면(flat/그룹 모두) 툴바(정렬·페이지크기·페이지네이션) 노출.
113
114
  // (그룹 뷰도 flat과 동일하게 페이지네이션 — 카드 4 결정 1안. 그룹은 페이지 단위로 재그룹핑되어 표시.)
@@ -85,10 +85,14 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
85
85
  // 글자 모드에서 마지막으로 선택돼 있던 모음 자모 — 발음↔글자 왕복 시 병합 버튼(ㅔㅐ 등)의
86
86
  // 대표 자모로 뭉개지지 않고 원래 자모를 정확히 복원하기 위해 별도로 기억해 둔다.
87
87
  const lastCharJungRef = useRef(phonemeView === 'char' ? composition.jung : null);
88
+ // 종성도 — 발음은 대표음(ㅅ→ㄷ)으로 모으므로 되돌릴 원래 자모를 기억한다
89
+ const lastCharJongRef = useRef(phonemeView === 'char' ? composition.jong : null);
88
90
  useEffect(() => {
89
- if (phonemeView === 'char')
91
+ if (phonemeView === 'char') {
90
92
  lastCharJungRef.current = composition.jung;
91
- }, [phonemeView, composition.jung]);
93
+ lastCharJongRef.current = composition.jong;
94
+ }
95
+ }, [phonemeView, composition.jung, composition.jong]);
92
96
  // resetOnMount(단어훈련 만들기): '초기화'는 필터를 모두 비운 중립 상태여야 하므로 선택 토글도 OFF로 시작한다.
93
97
  // (기본 ON이면 단음절 음소 합치기 등에서 '이미지 있는 단어만'에 걸려 첫 진입 결과가 0이 된다.)
94
98
  // 단, 카드에 이미지가 필요한 타입(듣고 순서대로 기억하기 등)은 defaultImageOnly로 ON을 요청할 수 있다.
@@ -109,7 +113,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
109
113
  return;
110
114
  resetKeyRef.current = opts?.resetKey;
111
115
  setComposition(seedComposition());
112
- setExcludeNoImage(false);
116
+ // 이미지 토글도 그 타입의 기본값으로 — 무조건 false 로 두어 defaultImageOnly 를 요청한 타입(듣고 순서대로 기억하기)이 꺼졌다
117
+ setExcludeNoImage(!!opts?.defaultImageOnly);
113
118
  setSameStructureOnly(false);
114
119
  setGradeFilter([]);
115
120
  setPosFilter([]);
@@ -520,7 +525,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
520
525
  if (next === phonemeView)
521
526
  return;
522
527
  if (next !== 'direct' && phonemeView !== 'direct') {
523
- setComposition(c => convertComposition(c, next, lastCharJungRef.current));
528
+ setComposition(c => convertComposition(c, next, lastCharJungRef.current, lastCharJongRef.current));
524
529
  }
525
530
  setPhonemeView(next);
526
531
  }, [phonemeView]);
@@ -51,7 +51,7 @@ export declare function charToPronValue(part: CompositionPart, jamo: string): st
51
51
  */
52
52
  export declare function pronToCharValue(part: CompositionPart, value: string, preferredJamo?: string | null): string;
53
53
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
54
- export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
54
+ export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
55
55
  /** 정답 단어 구성 선택 상태 (정답 단어에만 적용 — 오답은 대립 관계로 결정) */
56
56
  export interface WordComposition {
57
57
  cho: string | null;
@@ -83,10 +83,15 @@ export function pronToCharValue(part, value, preferredJamo) {
83
83
  return preferredJamo;
84
84
  return jamos[0] ?? value;
85
85
  }
86
+ /* 종성도 같다 — 글자→발음은 대표음(ㅅ·ㅈ·ㅊ·ㅌ·ㅎ → ㄷ, ㄳ → ㄱ)으로 모으므로, 되돌릴 때 전환 직전 글자 모드의 종성이
87
+ 그 대표음에 속하면 그것을 복원한다. 전에는 대표음 그대로 돌아와 보기만 바꿔도 훈련 대상이 옷·낫에서 곧·받으로
88
+ 바뀌어 저장됐다. */
89
+ if (part === 'jong' && preferredJamo && finalToPhoneme(preferredJamo) === value)
90
+ return preferredJamo;
86
91
  return value;
87
92
  }
88
93
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
89
- export function convertComposition(c, to, preferredCharJung) {
94
+ export function convertComposition(c, to, preferredCharJung, preferredCharJong) {
90
95
  const conv = (part, v) => {
91
96
  if (!v)
92
97
  return null;
@@ -94,7 +99,7 @@ export function convertComposition(c, to, preferredCharJung) {
94
99
  return NO_CODA;
95
100
  if (to === 'pron')
96
101
  return charToPronValue(part, v);
97
- return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : null);
102
+ return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : part === 'jong' ? preferredCharJong : null);
98
103
  };
99
104
  return { ...c, cho: conv('cho', c.cho), jung: conv('jung', c.jung), jong: conv('jong', c.jong) };
100
105
  }
@@ -1390,5 +1390,16 @@
1390
1390
  },
1391
1391
  "loader": {
1392
1392
  "loading": "Loading"
1393
+ },
1394
+ "soundCompass": {
1395
+ "state": {
1396
+ "active": "playing",
1397
+ "played": "heard",
1398
+ "correct": "correct",
1399
+ "wrong": "wrong"
1400
+ }
1401
+ },
1402
+ "breadcrumb": {
1403
+ "ariaLabel": "Breadcrumb"
1393
1404
  }
1394
1405
  }
@@ -1388,5 +1388,16 @@
1388
1388
  },
1389
1389
  "loader": {
1390
1390
  "loading": "불러오는 중…"
1391
+ },
1392
+ "soundCompass": {
1393
+ "state": {
1394
+ "active": "소리 나는 중",
1395
+ "played": "들음",
1396
+ "correct": "정답",
1397
+ "wrong": "오답"
1398
+ }
1399
+ },
1400
+ "breadcrumb": {
1401
+ "ariaLabel": "이동 경로"
1391
1402
  }
1392
1403
  }