@ihabdevteam/core 0.128.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core-tokens.css +1 -1
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.css +1 -1
- package/dist/ihabdevteam-core.d.ts +282 -80
- package/dist/packages/core/src/components/AudioMeter.js +29 -15
- package/dist/packages/core/src/components/Avatar.js +6 -2
- package/dist/packages/core/src/components/Badge.d.ts +5 -1
- package/dist/packages/core/src/components/Badge.js +19 -5
- package/dist/packages/core/src/components/BottomNav.css +15 -0
- package/dist/packages/core/src/components/BottomNav.d.ts +6 -0
- package/dist/packages/core/src/components/BottomNav.js +12 -1
- package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
- package/dist/packages/core/src/components/BulkBar.js +9 -2
- package/dist/packages/core/src/components/Calendar.js +1 -1
- package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
- package/dist/packages/core/src/components/ColorPicker.js +4 -2
- package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
- package/dist/packages/core/src/components/DatePicker.js +9 -4
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
- package/dist/packages/core/src/components/EmptyState.js +3 -1
- package/dist/packages/core/src/components/ErrorState.js +3 -1
- package/dist/packages/core/src/components/FilterPanel.js +7 -0
- package/dist/packages/core/src/components/Header.d.ts +6 -0
- package/dist/packages/core/src/components/Header.js +27 -1
- package/dist/packages/core/src/components/Icon.css +22 -0
- package/dist/packages/core/src/components/Icon.d.ts +7 -3
- package/dist/packages/core/src/components/Icon.js +7 -1
- package/dist/packages/core/src/components/IconGridMenu.css +4 -2
- package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
- package/dist/packages/core/src/components/IconGridMenu.js +6 -3
- package/dist/packages/core/src/components/LandingHeader.js +6 -3
- package/dist/packages/core/src/components/Loader.d.ts +1 -1
- package/dist/packages/core/src/components/Loader.js +7 -2
- package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
- package/dist/packages/core/src/components/NoticeBox.js +3 -1
- package/dist/packages/core/src/components/Pagination.d.ts +2 -2
- package/dist/packages/core/src/components/Pagination.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +5 -1
- package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
- package/dist/packages/core/src/components/PassCard/PassCardBg.css +33 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
- package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
- package/dist/packages/core/src/components/Popup/Popup.css +1 -1
- package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
- package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
- package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
- package/dist/packages/core/src/components/PromoBanner.js +23 -6
- package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
- package/dist/packages/core/src/components/RadioGroup.js +2 -2
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
- package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
- package/dist/packages/core/src/components/Section.d.ts +7 -1
- package/dist/packages/core/src/components/Section.js +6 -2
- package/dist/packages/core/src/components/Table.d.ts +1 -1
- package/dist/packages/core/src/components/TextField.css +2 -1
- package/dist/packages/core/src/components/TextField.js +10 -2
- package/dist/packages/core/src/components/Toggle.js +4 -1
- package/dist/packages/core/src/components/Tooltip/DebugTooltip.d.ts +1 -6
- package/dist/packages/core/src/components/Tooltip/DebugTooltip.js +4 -7
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +11 -1
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +3 -3
- package/dist/packages/core/src/components/Training/TrainingBlock.d.ts +8 -3
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +10 -11
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +4 -4
- package/dist/packages/core/src/components/Training/trainingScenario.d.ts +6 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +6 -0
- package/dist/packages/core/src/components/Word/WordTableButton.d.ts +2 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +1 -1
- package/dist/packages/core/src/components/Word/wordTableScoring.d.ts +1 -4
- package/dist/packages/core/src/components/Word/wordTableScoring.js +1 -4
- package/dist/packages/core/src/components/WordTable.d.ts +1 -1
- package/dist/packages/core/src/components/WordTable.js +3 -1
- package/dist/packages/core/src/components.d.ts +3 -0
- package/dist/packages/core/src/components.js +5 -0
- package/dist/packages/core/src/hooks/useDeviceManagement.js +11 -2
- package/dist/packages/core/src/hooks/useFontScale.js +3 -1
- package/dist/packages/core/src/hooks/useLucideIcon.d.ts +3 -4
- package/dist/packages/core/src/hooks/useLucideIcon.js +3 -4
- package/dist/packages/core/src/hooks/usePasswordChange.js +20 -7
- package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +7 -0
- package/dist/packages/core/src/hooks/useVisitAllPanel.js +8 -0
- package/dist/packages/core/src/hooks/useWordMeanings.js +3 -0
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +5 -0
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +23 -33
- package/dist/packages/core/src/i18n/locales/en.json +34 -6
- package/dist/packages/core/src/i18n/locales/ko.json +34 -6
- package/dist/packages/core/src/tokens.css +2 -1
- package/dist/packages/core/src/utils/CategoryData.d.ts +12 -40
- package/dist/packages/core/src/utils/CategoryData.js +15 -10
- package/dist/packages/core/src/utils/assets.js +7 -3
- package/dist/packages/core/src/utils/clipboard.js +16 -2
- package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
- package/dist/packages/core/src/utils/errorMessage.js +19 -2
- package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
- package/dist/packages/core/src/utils/getTrainingData.js +8 -1
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
- package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
- package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
- package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
- package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +4 -2
|
@@ -43,6 +43,32 @@ export function BrandIdentity({ appMode }) {
|
|
|
43
43
|
export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange }) {
|
|
44
44
|
const { t } = useTranslation();
|
|
45
45
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
46
|
+
/* **모바일 하단 내비에 SideNav 항목을 옮겨 적는다.** 예전에는 `SideNavItem` 을 `NavItem` 으로
|
|
47
|
+
캐스팅만 해 넘겨, 두 타입의 이름이 다른 칸이 조용히 버려졌다 — `disabled` 가 `state` 로
|
|
48
|
+
안 가서 **비활성 항목이 모바일에서 눌렸고**, `href` 항목은 새 창 대신 `onChange(key)` 로
|
|
49
|
+
갔다. SideNav 가 거르는 구획(section)·빈 항목도 같이 거른다.
|
|
50
|
+
`badge` 는 `badgeCount` 로 옮긴다 — BottomNav 가 단추 모서리(넘치면 "…" 단추와 메뉴 항목)에
|
|
51
|
+
SideNav 와 같은 원형 숫자 배지로 그린다. 예전엔 받을 자리가 없어 숙제 개수가 모바일에서 안 보였다. */
|
|
52
|
+
const bottomItems = useMemo(() => (items ?? [])
|
|
53
|
+
.filter(it => !it.section && (it.key || it.label))
|
|
54
|
+
.map(it => ({
|
|
55
|
+
key: it.key,
|
|
56
|
+
label: typeof it.label === 'string' ? it.label : undefined,
|
|
57
|
+
icon: it.icon,
|
|
58
|
+
state: it.disabled ? 'disabled' : undefined,
|
|
59
|
+
badgeCount: it.badge,
|
|
60
|
+
})), [items]);
|
|
61
|
+
const handleBottomChange = useCallback((key) => {
|
|
62
|
+
const it = items?.find(i => i.key === key);
|
|
63
|
+
if (it?.disabled)
|
|
64
|
+
return;
|
|
65
|
+
// SideNav 와 같다 — href 가 있으면 앱 안 이동이 아니라 새 창으로 연다.
|
|
66
|
+
if (it?.href) {
|
|
67
|
+
window.open(it.href, '_blank', 'noopener,noreferrer');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
onChange?.(key);
|
|
71
|
+
}, [items, onChange]);
|
|
46
72
|
// 모바일 하단 더보기(…) 메뉴에 노출할 유틸(알림·설정·도움말·계정·디버그).
|
|
47
73
|
// 데스크톱 SideNav 푸터와 동일한 아이콘/키를 사용해 활성표시가 일관되게 반영된다.
|
|
48
74
|
const bottomMoreItems = useMemo(() => {
|
|
@@ -86,7 +112,7 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
|
|
|
86
112
|
onClick: () => { it.onClick?.(); closeUtilMenu(); },
|
|
87
113
|
})),
|
|
88
114
|
}], [bottomMoreItems, activeKey, closeUtilMenu]);
|
|
89
|
-
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, { 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:
|
|
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, { 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 })] }));
|
|
90
116
|
}
|
|
91
117
|
/**
|
|
92
118
|
* `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* ── Icon 마스크 모드 ────────────────────────────────────────────────
|
|
2
|
+
`.svg` 경로가 SVG 로 응답하면 Icon 은 `<span class="iconRenderer-icon-mask"
|
|
3
|
+
style="--icon-url: url(…)">` 를 그린다. 그런데 이 이름에 규칙이 하나도 없어서
|
|
4
|
+
크기 0 · 마스크 없음 — **아무것도 안 그려졌다.** Button·SideNav 는 제
|
|
5
|
+
maskClassName 에 같은 규칙을 따로 두어 괜찮았고, 기본 이름으로 쓰는 자리만 비었다.
|
|
6
|
+
|
|
7
|
+
새 색은 쓰지 않는다 — 글자색(currentColor)으로 칠해 부모의 color 를 따른다.
|
|
8
|
+
크기는 `size` prop 이 `--icon-size` 로 넘기고, 없으면 lucide 기본과 같은 20px. */
|
|
9
|
+
.iconRenderer-icon-mask {
|
|
10
|
+
display: inline-block;
|
|
11
|
+
width: var(--icon-size, 20px);
|
|
12
|
+
height: var(--icon-size, 20px);
|
|
13
|
+
background-color: currentColor;
|
|
14
|
+
-webkit-mask-image: var(--icon-url);
|
|
15
|
+
-webkit-mask-repeat: no-repeat;
|
|
16
|
+
-webkit-mask-position: center;
|
|
17
|
+
-webkit-mask-size: contain;
|
|
18
|
+
mask-image: var(--icon-url);
|
|
19
|
+
mask-repeat: no-repeat;
|
|
20
|
+
mask-position: center;
|
|
21
|
+
mask-size: contain;
|
|
22
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReactNode, type CSSProperties } from 'react';
|
|
2
|
+
import './Icon.css';
|
|
2
3
|
/** @public */
|
|
3
4
|
export interface IconProps {
|
|
4
5
|
/**
|
|
@@ -11,8 +12,8 @@ export interface IconProps {
|
|
|
11
12
|
/**
|
|
12
13
|
* 감싸는 `<span>` 의 클래스. 주면 기본 이름을 **갈아치운다** — 덧붙이지 않는다.
|
|
13
14
|
*
|
|
14
|
-
* 기본값 `iconRenderer-icon` 을 비롯한
|
|
15
|
-
* 제 규칙이
|
|
15
|
+
* 기본값 `iconRenderer-icon` 을 비롯한 `iconRenderer-*` 이름은 마스크(`-mask`)를 빼면
|
|
16
|
+
* **패키지에 제 규칙이 없다**. 붙잡을 이름일 뿐이고 크기는
|
|
16
17
|
* `size`, 그 밖의 모양은 `wrapperStyle` 이나 소비자 CSS 로 간다. 그래서
|
|
17
18
|
* `className="iconRenderer-icon"` 이라고 적어도 아무 모양도 따라오지 않는다.
|
|
18
19
|
*
|
|
@@ -23,7 +24,10 @@ export interface IconProps {
|
|
|
23
24
|
className?: string;
|
|
24
25
|
/** `<img>` 로 그릴 때의 클래스. 기본 `iconRenderer-icon-image` — 규칙 없음. */
|
|
25
26
|
imgClassName?: string;
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 마스크로 그릴 때의 클래스. 기본 `iconRenderer-icon-mask` — 글자색으로 칠하고 크기는 `size`(없으면 20px).
|
|
29
|
+
* 주면 기본 이름을 갈아치우므로 마스크 규칙(`mask-image: var(--icon-url)` 등)도 그 클래스가 대야 한다.
|
|
30
|
+
*/
|
|
27
31
|
maskClassName?: string;
|
|
28
32
|
/** 인라인 SVG 로 그릴 때의 클래스. 기본 `iconRenderer-icon-svg` — 규칙 없음. */
|
|
29
33
|
svgClassName?: string;
|
|
@@ -3,6 +3,7 @@ import { createElement } from 'react';
|
|
|
3
3
|
import useLucideIcon from '../hooks/useLucideIcon';
|
|
4
4
|
import useIcon from '../hooks/useIcon';
|
|
5
5
|
import { resolveAssetUrl } from '../utils';
|
|
6
|
+
import './Icon.css';
|
|
6
7
|
/** @public */
|
|
7
8
|
export default function Icon(props) {
|
|
8
9
|
const { icon, label, className, imgClassName, maskClassName, svgClassName, wrapperStyle, size } = props || {};
|
|
@@ -41,7 +42,12 @@ export default function Icon(props) {
|
|
|
41
42
|
if (iconToUse && iconToUse.endsWith('.svg')) {
|
|
42
43
|
if (mode === 'mask') {
|
|
43
44
|
const urlValue = blobUrl ? `url(${blobUrl})` : `url(${iconToUse})`;
|
|
44
|
-
|
|
45
|
+
// 크기는 기본 마스크 규칙(Icon.css)이 --icon-size 로 읽는다. width/height 를 직접 박으면
|
|
46
|
+
// 제 maskClassName 으로 크기를 정하는 Button·SideNav 를 덮어 버리므로 변수로만 건넨다.
|
|
47
|
+
const maskStyle = {
|
|
48
|
+
'--icon-url': urlValue,
|
|
49
|
+
...(typeof size === 'number' ? { '--icon-size': `${size}px` } : null),
|
|
50
|
+
};
|
|
45
51
|
return createWrapper(_jsx("span", { className: defaultMaskClass, style: maskStyle }), { 'aria-hidden': 'true' });
|
|
46
52
|
}
|
|
47
53
|
if (mode === 'img') {
|
|
@@ -35,13 +35,15 @@
|
|
|
35
35
|
box-shadow: var(--shadow-card);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/* 고른 단추 — 예전에는 `:disabled` 로 그렸다. 이제 disabled 를 쓰지 않으므로(IconGridMenu.tsx 참고)
|
|
39
|
+
같은 모양을 `--selected` 에 옮긴다. 값은 그대로. 단 `outline: none` 은 옮기지 않는다 —
|
|
40
|
+
이제 Tab 으로 닿으므로 포커스 표시가 보여야 한다. */
|
|
41
|
+
.icon-grid-menu__button.icon-grid-menu__button--selected {
|
|
39
42
|
cursor: default;
|
|
40
43
|
opacity: 0.95;
|
|
41
44
|
box-shadow: none;
|
|
42
45
|
border-color: var(--color-whiteLine);
|
|
43
46
|
pointer-events: none;
|
|
44
|
-
outline: none;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
.icon-grid-menu__button-icon {
|
|
@@ -10,7 +10,7 @@ export interface IconGridMenuCategory {
|
|
|
10
10
|
/** @public */
|
|
11
11
|
export interface IconGridMenuProps {
|
|
12
12
|
categories?: IconGridMenuCategory[];
|
|
13
|
-
/** 현재 선택된 카테고리 key (controlled) */
|
|
13
|
+
/** 현재 선택된 카테고리 key (controlled). `null` 이면 아무것도 고르지 않은 상태, 주지 않으면 첫 카테고리로 시작한다 */
|
|
14
14
|
selected?: string | null;
|
|
15
15
|
onSelect?: (key: string | null) => void;
|
|
16
16
|
/** 버튼 크기 — `medium`(기본, 68px) / `small`(48px) */
|
|
@@ -11,17 +11,20 @@ function IconGridMenuButton(props) {
|
|
|
11
11
|
// 이름표를 aria-labelledby 로 가리킨다 — label 이 문자열이 아니라 JSX 일 수도 있어
|
|
12
12
|
// aria-label 로 옮겨 적는 대신 화면에 이미 있는 그 요소를 그대로 이름으로 삼는다.
|
|
13
13
|
const labelId = useId();
|
|
14
|
-
return (_jsxs("div", { className: `icon-grid-menu__button-wrapper${keyClass}`, children: [_jsx("button", { className: `icon-grid-menu__button${isSelected ? ' icon-grid-menu__button--selected' : ''}`, "data-key": category?.key, onClick: isSelected ? undefined : onClick, "aria-pressed": isSelected, "aria-labelledby": labelId,
|
|
14
|
+
return (_jsxs("div", { className: `icon-grid-menu__button-wrapper${keyClass}`, children: [_jsx("button", { className: `icon-grid-menu__button${isSelected ? ' icon-grid-menu__button--selected' : ''}`, "data-key": category?.key, type: "button", onClick: isSelected ? undefined : onClick, "aria-pressed": isSelected, "aria-labelledby": labelId, children: _jsx(Icon, { icon: category.icon, label: category.label, className: "icon-grid-menu__button-icon", imgClassName: "icon-grid-menu__button-icon-image", maskClassName: "icon-grid-menu__button-icon-mask", svgClassName: "icon-grid-menu__button-icon-svg" }) }), _jsx("span", { id: labelId, className: "icon-grid-menu__button-label button-1", children: category.label })] }));
|
|
15
15
|
}
|
|
16
16
|
/** @public */
|
|
17
17
|
export default function IconGridMenu(props) {
|
|
18
18
|
const { categories = [], selected, onSelect, size = 'medium' } = props || {};
|
|
19
|
-
|
|
19
|
+
/* `null` 은 "고르지 않음" 이라는 제어 값이다 — 첫 카테고리로 갈음하지 않는다 */
|
|
20
|
+
const initial = selected !== undefined ? selected : ((categories && categories[0]?.key) ?? null);
|
|
20
21
|
const [selectedKey, setSelectedKey] = useState(initial ?? null);
|
|
21
22
|
// 외부 selected prop이 controlled로 전달되면 내부 상태 동기화하는 의도된 동기화다
|
|
22
23
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
23
24
|
useEffect(() => {
|
|
24
|
-
|
|
25
|
+
/* null 도 따른다 — 예전에는 null 을 걸러 버려, 부모가 selected={null} 로 선택을 풀어도
|
|
26
|
+
단추는 골라진 채로 남았다. */
|
|
27
|
+
if (selected !== undefined) {
|
|
25
28
|
setSelectedKey(selected);
|
|
26
29
|
}
|
|
27
30
|
}, [selected]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useId, useState } from 'react';
|
|
4
4
|
import './LandingHeader.css';
|
|
5
5
|
/**
|
|
6
6
|
* 랜딩/마케팅 페이지용 상단 고정 헤더.
|
|
@@ -19,6 +19,9 @@ import './LandingHeader.css';
|
|
|
19
19
|
*/
|
|
20
20
|
export default function LandingHeader({ logo, logoAlt = '', logoHref, links = [], actions, className, containerClassName, renderLink, menuLabel = { open: '메뉴 열기', close: '메뉴 닫기' }, }) {
|
|
21
21
|
const [open, setOpen] = useState(false);
|
|
22
|
+
// 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
|
|
23
|
+
// 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
|
|
24
|
+
const drawerId = useId();
|
|
22
25
|
const innerClass = containerClassName
|
|
23
26
|
? `${containerClassName} landing-header__inner`
|
|
24
27
|
: 'landing-header__inner';
|
|
@@ -34,7 +37,7 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
|
|
|
34
37
|
return logoHref
|
|
35
38
|
? _jsx("a", { href: logoHref, className: "landing-header__brand", children: brand })
|
|
36
39
|
: _jsx("div", { className: "landing-header__brand", children: brand });
|
|
37
|
-
})(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map(item => (_jsx("span", { children: link(item) }, item.href))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls":
|
|
40
|
+
})(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map(item => (_jsx("span", { children: link(item) }, item.href))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls": drawerId, "aria-label": open ? menuLabel.close : menuLabel.open, className: open
|
|
38
41
|
? 'landing-header__toggle landing-header__toggle--open'
|
|
39
|
-
: 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id:
|
|
42
|
+
: 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id: drawerId, hidden: !open, className: "landing-header__drawer", children: _jsx("div", { className: containerClassName ?? undefined, children: links.map(item => (_jsx("span", { className: "landing-header__drawer-item", children: link(item, () => setOpen(false)) }, item.href))) }) }))] }));
|
|
40
43
|
}
|
|
@@ -7,7 +7,7 @@ export interface LoaderProps {
|
|
|
7
7
|
size?: number | string;
|
|
8
8
|
/** 진행률(0–100). `variant='bar'`일 때 적용됩니다. */
|
|
9
9
|
progress?: number | null;
|
|
10
|
-
/** aria-label
|
|
10
|
+
/** 접근성 이름(aria-label). 화면에 글자로 나오지는 않는다. 안 주면 i18n 의 `loader.loading`(기본 'Loading'). */
|
|
11
11
|
label?: string;
|
|
12
12
|
}
|
|
13
13
|
/** @public */
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
2
4
|
import './Loader.css';
|
|
3
5
|
/** @public */
|
|
4
6
|
export default function Loader(props) {
|
|
7
|
+
const { t } = useTranslation();
|
|
5
8
|
const { variant = 'spinner', size = 24, progress = null, label } = props || {};
|
|
9
|
+
// 기본 이름이 영문 고정이라 한국어 화면에서도 낭독기가 'Loading' 으로 읽었다 — 사전을 거친다.
|
|
10
|
+
const name = label || t('loader.loading', 'Loading');
|
|
6
11
|
if (variant === 'bar') {
|
|
7
12
|
const pct = Math.max(0, Math.min(100, Number(progress) || 0));
|
|
8
|
-
return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label":
|
|
13
|
+
return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: { width: `${pct}%` } }) }));
|
|
9
14
|
}
|
|
10
15
|
const dim = typeof size === 'number' ? `${size}px` : size;
|
|
11
|
-
return (_jsx("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label":
|
|
16
|
+
return (_jsx("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] }) }));
|
|
12
17
|
}
|
|
@@ -4,9 +4,14 @@ import './NoticeBox.css';
|
|
|
4
4
|
export interface NoticeBoxProps {
|
|
5
5
|
title?: string;
|
|
6
6
|
lines?: string[];
|
|
7
|
+
/** 여러 안내를 한 상자에 차례로 싣는다. 주면 `title`·`lines`·`children` 대신 이것을 그린다. */
|
|
7
8
|
notices?: Array<{
|
|
8
9
|
title?: string;
|
|
9
10
|
lines?: string[];
|
|
11
|
+
/**
|
|
12
|
+
* 효과 없음 — 상자 모양은 바깥 `variant` 하나로만 정해진다.
|
|
13
|
+
* @deprecated 읽히지 않는 값이다. 항목마다 모양을 달리 그리는 스타일이 없다 — 모양이 다른 안내는 NoticeBox 를 따로 둔다.
|
|
14
|
+
*/
|
|
10
15
|
variant?: string;
|
|
11
16
|
children?: ReactNode;
|
|
12
17
|
}> | null;
|
|
@@ -15,5 +15,7 @@ export default function NoticeBox({ title = '', lines = [], notices = null, chil
|
|
|
15
15
|
return (_jsx("div", { className: baseClass, role: "note", children: notices.map((n, idx) => (_jsxs("div", { className: "notice-box__inner", children: [n.title ? _jsx("span", { className: "notice-box__title body-3", children: n.title }) : null, _jsx("div", { className: "notice-box__body", children: n.lines && n.lines.length ? (_jsx("div", { children: n.lines.map((l, i) => (_jsx("span", { className: "notice-box__line body-3", children: l }, i))) })) : (n.children ?? null) })] }, idx))) }));
|
|
16
16
|
}
|
|
17
17
|
const content = (title || lines.length) ? (_jsxs("div", { className: "notice-box__inner", children: [title ? _jsx("span", { className: "notice-box__title body-1", children: title }) : null, _jsx("div", { className: "notice-box__body", children: lines.length ? (_jsx("ul", { children: lines.map((l, i) => (_jsx("li", { className: "notice-box__line", children: l }, i))) })) : children })] })) : (_jsx("div", { className: "notice-box__inner", children: children }));
|
|
18
|
-
return (_jsxs("div", { className: baseClass, role: "note", children: [content, dismissible && (_jsx("button", {
|
|
18
|
+
return (_jsxs("div", { className: baseClass, role: "note", children: [content, dismissible && (_jsx("button", {
|
|
19
|
+
// type="button" — 없으면 폼 안에서 닫기를 누르는 순간 폼이 제출된다
|
|
20
|
+
type: "button", className: "notice-box__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
|
|
19
21
|
}
|
|
@@ -14,8 +14,8 @@ export interface PaginationProps {
|
|
|
14
14
|
className?: string;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* 페이지네이션 컨트롤 (‹ 현재/전체 ›).
|
|
18
|
-
*
|
|
17
|
+
* 페이지네이션 컨트롤 (‹ 현재/전체 ›). 표 아래에 두고 `page`·`totalPages` 를 넘기면
|
|
18
|
+
* 이전·다음 단추가 `onChange` 로 1~`totalPages` 안의 쪽 번호를 돌려준다. 끝에서는 그쪽 단추가 꺼진다.
|
|
19
19
|
*
|
|
20
20
|
* @public
|
|
21
21
|
*/
|
|
@@ -5,8 +5,8 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import Button from './Button';
|
|
6
6
|
import './Pagination.css';
|
|
7
7
|
/**
|
|
8
|
-
* 페이지네이션 컨트롤 (‹ 현재/전체 ›).
|
|
9
|
-
*
|
|
8
|
+
* 페이지네이션 컨트롤 (‹ 현재/전체 ›). 표 아래에 두고 `page`·`totalPages` 를 넘기면
|
|
9
|
+
* 이전·다음 단추가 `onChange` 로 1~`totalPages` 안의 쪽 번호를 돌려준다. 끝에서는 그쪽 단추가 꺼진다.
|
|
10
10
|
*
|
|
11
11
|
* @public
|
|
12
12
|
*/
|
|
@@ -54,7 +54,11 @@ export function PanelRow(props) {
|
|
|
54
54
|
const p = props;
|
|
55
55
|
return (_jsx("div", { className: "panel-row panel-row--block", children: p.children }));
|
|
56
56
|
}
|
|
57
|
-
const { variant, label, sub, onClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
|
|
57
|
+
const { variant, label, sub, onClick: baseOnClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
|
|
58
|
+
// button 행의 onClick 은 **단추의 것**이다(PanelRowButton.onClick). 행에도 같이 걸면
|
|
59
|
+
// 단추를 누른 클릭이 행으로 올라와 한 번 누름에 두 번 불렸다. 행 전체 클릭은 button 이 아닌
|
|
60
|
+
// variant 에서만 받는다.
|
|
61
|
+
const onClick = variant === 'button' ? undefined : baseOnClick;
|
|
58
62
|
let right = null;
|
|
59
63
|
// toggle 행만 왼쪽 라벨(panel-row__label)을 <label htmlFor>로 렌더해 스위치와 프로그래밍적으로
|
|
60
64
|
// 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
|
|
@@ -16,7 +16,12 @@ export interface PassCardProps {
|
|
|
16
16
|
/** 기간 토큰(예: '30일'). 미지정 시 product에서 도출. */
|
|
17
17
|
plan?: string;
|
|
18
18
|
type?: PassCardType;
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated 읽지 않는다 — 카드는 시작일을 그리지 않고, 남은 기간은 `endDate`(또는 `remainingSeconds`)로만 센다.
|
|
21
|
+
* 넘겨도 화면은 같다.
|
|
22
|
+
*/
|
|
19
23
|
startDate?: string;
|
|
24
|
+
/** 끝나는 날(YYYY-MM-DD). `state` 가 active·expired 이고 `remainingSeconds` 가 없으면 이 날까지 남은 날수로 사용량을 센다. */
|
|
20
25
|
endDate?: string;
|
|
21
26
|
/**
|
|
22
27
|
* 전환으로 일시정지되어 남은 시간(초)이 알려진 경우에만 지정.
|
|
@@ -26,8 +31,12 @@ export interface PassCardProps {
|
|
|
26
31
|
*/
|
|
27
32
|
remainingSeconds?: number;
|
|
28
33
|
state?: PassCardState;
|
|
34
|
+
/** @deprecated 읽지 않는다 — 대기 순서는 카드에 그리지 않는다. 넘겨도 화면은 같다. */
|
|
29
35
|
queueIndex?: number;
|
|
30
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* 배경 컴포넌트. 안 주면 종류·기간·라벨로 고른다 — 말귀(`speechear`)는 늘 PassCardBgTwoCircle,
|
|
38
|
+
* 말귀 매니저(`manager`)는 1년이면 PassCardBgTalk, 라벨이 오픈특가면 PassCardBgSoundMoveV1, 그 밖에는 PassCardBgSoundMove.
|
|
39
|
+
*/
|
|
31
40
|
bg?: ReactNode;
|
|
32
41
|
/** true면 배경만 표시하고 콘텐츠(로고·플랜·카피라이트)를 숨김 */
|
|
33
42
|
bgOnly?: boolean;
|
|
@@ -37,3 +37,36 @@
|
|
|
37
37
|
-webkit-mask: var(--symbol-url) center / contain no-repeat;
|
|
38
38
|
mask: var(--symbol-url) center / contain no-repeat;
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/* ── PassCardBg 컴포넌트 자체의 변형(default · blue · dark) ─────────────────
|
|
42
|
+
9종 배경은 각자 파일에 모양을 두고, 이 기본 컴포넌트의 원 셋과 변형은 규칙이 없어
|
|
43
|
+
세 변형이 똑같이(빈 바탕으로) 그려졌다(2026-09-14, 사용자가 정하도록 맡김).
|
|
44
|
+
**새 색은 쓰지 않는다** — 원 구도는 PassCardBgTwoCircle 과 같게(큰 원 우상단 · 작은 원
|
|
45
|
+
우하단)에 좌하단 원 하나를 더하고, 색은 브랜드·중립 토큰만 쓴다. 만료 카드
|
|
46
|
+
(.pass-card--expired)가 --color-main/sub 를 회색으로 바꾸는 규칙도 그대로 따라온다. */
|
|
47
|
+
.pass-card-bg__circle {
|
|
48
|
+
position: absolute;
|
|
49
|
+
border-radius: 50%;
|
|
50
|
+
opacity: var(--pass-card-symbol-opacity, 0.75);
|
|
51
|
+
}
|
|
52
|
+
.pass-card-bg__circle--1 { width: 340px; height: 340px; top: -140px; right: -150px; }
|
|
53
|
+
.pass-card-bg__circle--2 { width: 220px; height: 220px; bottom: -90px; right: -50px; }
|
|
54
|
+
.pass-card-bg__circle--3 { width: 140px; height: 140px; bottom: -70px; left: -40px; }
|
|
55
|
+
|
|
56
|
+
/* default — 흰 바탕 + main · sub 원(TwoCircle 과 같은 결) */
|
|
57
|
+
.pass-card-bg--default { background: var(--color-pureWhite); }
|
|
58
|
+
.pass-card-bg--default .pass-card-bg__circle--1 { background: var(--color-main); }
|
|
59
|
+
.pass-card-bg--default .pass-card-bg__circle--2 { background: var(--color-sub); }
|
|
60
|
+
.pass-card-bg--default .pass-card-bg__circle--3 { background: var(--color-main-a20); }
|
|
61
|
+
|
|
62
|
+
/* blue — 짙은 main 바탕 + 흰 반투명 원 */
|
|
63
|
+
.pass-card-bg--blue { background: var(--color-deepMain); }
|
|
64
|
+
.pass-card-bg--blue .pass-card-bg__circle--1 { background: var(--color-white-a30); }
|
|
65
|
+
.pass-card-bg--blue .pass-card-bg__circle--2 { background: var(--color-white-a10); }
|
|
66
|
+
.pass-card-bg--blue .pass-card-bg__circle--3 { background: var(--color-white-a10); }
|
|
67
|
+
|
|
68
|
+
/* dark — 검은 바탕 + main · sub 원 */
|
|
69
|
+
.pass-card-bg--dark { background: var(--color-softBlack); }
|
|
70
|
+
.pass-card-bg--dark .pass-card-bg__circle--1 { background: var(--color-main); }
|
|
71
|
+
.pass-card-bg--dark .pass-card-bg__circle--2 { background: var(--color-sub); }
|
|
72
|
+
.pass-card-bg--dark .pass-card-bg__circle--3 { background: var(--color-white-a10); }
|
|
@@ -26,8 +26,15 @@ export interface PaymentPlanTier {
|
|
|
26
26
|
export interface PaymentPlanData {
|
|
27
27
|
id: string | number;
|
|
28
28
|
title?: string;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* 서비스명(말귀/말귀매니저). 이 컴포넌트는 읽지 않는다 — 카드 로고는 `group` 으로 고른다.
|
|
31
|
+
* 부르는 쪽이 상품 목록을 나누거나 표기할 때 쓰도록 자리만 둔 칸이다.
|
|
32
|
+
*/
|
|
30
33
|
service?: string;
|
|
34
|
+
/**
|
|
35
|
+
* 상품 묶음. `'org'` 면 이용권 카드를 말귀 매니저 모양으로, 그 밖(`'personal'` 등)이면 말귀 모양으로 그린다.
|
|
36
|
+
*/
|
|
37
|
+
group?: 'personal' | 'org' | (string & {});
|
|
31
38
|
/** 플랜 티어명(스탠다드/라이트/프로 등). 카드 헤드라인용. */
|
|
32
39
|
tier?: string;
|
|
33
40
|
subtitle?: string;
|
|
@@ -59,7 +59,7 @@ export interface ConfirmPopupProps {
|
|
|
59
59
|
onClose: () => void;
|
|
60
60
|
/** portal 없이 부모 컨테이너에 제자리 렌더 (목업/데모용) */
|
|
61
61
|
inline?: boolean;
|
|
62
|
-
/** 패널 최대 가로폭. 확인창 기본은 'small'(
|
|
62
|
+
/** 패널 최대 가로폭. 확인창 기본은 'small'(480px) */
|
|
63
63
|
size?: 'small' | 'medium' | 'large';
|
|
64
64
|
className?: string;
|
|
65
65
|
}
|
|
@@ -40,7 +40,14 @@ export interface PopupListProps<T extends {
|
|
|
40
40
|
selectedId?: string | number;
|
|
41
41
|
/** 현재 선택된 아이템 id Set — 다중 선택 강조 (`selectedId`보다 우선) */
|
|
42
42
|
selectedIds?: Set<string | number>;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* 효과 없음 — ListPopup 은 행을 골라도 **스스로 닫지 않는다**(닫기는 늘 `onSelect`·`onClose` 를
|
|
45
|
+
* 받은 쪽이 정한다). 그래서 이 값은 읽지 않는다.
|
|
46
|
+
*
|
|
47
|
+
* @deprecated 읽히지 않는 값이다. 예전 JSDoc 은 "기본 false = 고르면 닫힘" 이라 했지만 그렇게
|
|
48
|
+
* 동작한 적이 없고, 그 뜻대로 고치면 행 클릭이 아무것도 안 하는 화면(행 안 단추로만 보내는 창 등)이
|
|
49
|
+
* 누르는 순간 닫힌다. 고른 뒤 닫으려면 `onSelect` 안에서 직접 닫는다.
|
|
50
|
+
*/
|
|
44
51
|
keepOpenOnSelect?: boolean;
|
|
45
52
|
/** 행 선택 콜백 */
|
|
46
53
|
onSelect: (item: T) => void;
|
|
@@ -63,12 +63,27 @@ export default function ListPopup(props) {
|
|
|
63
63
|
}, [sortKey]);
|
|
64
64
|
// countLabel("총 {n}명")을 Toolbar count의 prefix/suffix로 분해
|
|
65
65
|
const countParts = useMemo(() => {
|
|
66
|
+
/* 함수 꼴은 예전에 통째로 무시되어 기본 "총 N개" 가 떴다(JSDoc 은 받는다고 했다).
|
|
67
|
+
Toolbar 는 숫자를 강조 칸에 따로 그리므로, 돌려받은 문자열에서 개수가 **마지막으로**
|
|
68
|
+
나오는 자리를 갈라 앞·뒤로 넘긴다 — 앞+숫자+뒤가 언제나 원래 문자열과 같아서
|
|
69
|
+
글은 틀리지 않는다(숫자가 여럿이면 강조 자리만 어긋날 수 있다). 문자열이 아니거나
|
|
70
|
+
개수가 안 들어 있으면 가를 수 없으니 받은 것을 그대로 그린다. */
|
|
71
|
+
if (typeof countLabel === 'function') {
|
|
72
|
+
const n = filtered.length;
|
|
73
|
+
const label = countLabel(n);
|
|
74
|
+
if (typeof label === 'string') {
|
|
75
|
+
const at = label.lastIndexOf(String(n));
|
|
76
|
+
if (at >= 0)
|
|
77
|
+
return { prefix: label.slice(0, at), suffix: label.slice(at + String(n).length) };
|
|
78
|
+
}
|
|
79
|
+
return { label };
|
|
80
|
+
}
|
|
66
81
|
if (typeof countLabel === 'string' && countLabel.includes('{n}')) {
|
|
67
82
|
const [prefix, suffix] = countLabel.split('{n}');
|
|
68
83
|
return { prefix, suffix };
|
|
69
84
|
}
|
|
70
85
|
return { prefix: t('common.totalCountPrefix', '총 '), suffix: t('common.totalCountSuffix', '개') };
|
|
71
|
-
}, [countLabel, t]);
|
|
86
|
+
}, [countLabel, filtered.length, t]);
|
|
72
87
|
const tableFields = useMemo(() => columns.map(col => ({
|
|
73
88
|
label: col.label,
|
|
74
89
|
onClick: col.sortable !== false ? () => handleSort(col.key) : undefined,
|
|
@@ -76,7 +91,7 @@ export default function ListPopup(props) {
|
|
|
76
91
|
sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
|
|
77
92
|
})), [columns, sortKey, sortAsc, handleSort]);
|
|
78
93
|
const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
|
|
79
|
-
return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, _jsx(Toolbar, { count: { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
|
|
94
|
+
return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
|
|
80
95
|
const isSelected = selectedIds != null
|
|
81
96
|
? (it.id != null && selectedIds.has(it.id))
|
|
82
97
|
: (selectedId != null && it.id === selectedId);
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
display: flex;
|
|
31
31
|
flex-direction: column;
|
|
32
32
|
}
|
|
33
|
-
/* size별 최대 가로폭 — 미지정 시 위의 기본(820px) 유지.
|
|
33
|
+
/* size별 최대 가로폭 — 미지정 시 위의 기본(820px) 유지. small 480 · medium 680 · large 1080 */
|
|
34
34
|
.popup-panel--small { max-width: 480px; }
|
|
35
35
|
.popup-panel--medium { max-width: 680px; }
|
|
36
36
|
.popup-panel--large { max-width: 1080px; }
|
|
@@ -69,7 +69,7 @@ export interface PopupProps {
|
|
|
69
69
|
* 패널 최대 가로폭 제한. 미지정 시 기본(820px) 유지.
|
|
70
70
|
* - `small` 480px (확인/알림류)
|
|
71
71
|
* - `medium` 680px (입력 폼)
|
|
72
|
-
* - `large`
|
|
72
|
+
* - `large` 1080px (리스트/다중 콘텐츠)
|
|
73
73
|
* 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
|
|
74
74
|
*/
|
|
75
75
|
size?: 'small' | 'medium' | 'large';
|
|
@@ -81,14 +81,15 @@ export default function TrainingResultPopup(props) {
|
|
|
81
81
|
const list = useMemo(() => Array.isArray(results) ? results : [], [results]);
|
|
82
82
|
const rows = useMemo(() => list.map((it) => {
|
|
83
83
|
const itr = it ?? {};
|
|
84
|
-
|
|
84
|
+
// 문제 글이 비었을 때의 자리표시도 번역을 거친다 — 영어 화면에 한국어가 박혀 나왔다
|
|
85
|
+
const problem = resolveProblem(itr) || t('training.result.noContent', '(내용없음)');
|
|
85
86
|
const { variant, label } = getJudge(itr);
|
|
86
87
|
const corrects = Array.isArray(itr.corrects) ? itr.corrects : [];
|
|
87
88
|
const answer = corrects.length ? corrects.join(', ') : (itr.answer != null && itr.answer !== '' ? String(itr.answer) : null);
|
|
88
89
|
const selectedText = typeof itr.selectedText === 'string' ? itr.selectedText : null;
|
|
89
90
|
const isCorrect = resolveDecision(itr) === true;
|
|
90
91
|
return { problem, variant, label, answer, selectedText, isCorrect };
|
|
91
|
-
}), [list, getJudge]);
|
|
92
|
+
}), [list, getJudge, t]);
|
|
92
93
|
const hasAnswerCols = useMemo(() => rows.some(r => r.answer !== null || r.selectedText !== null), [rows]);
|
|
93
94
|
// 스크롤은 Popup의 .popup-content 하나만 담당한다 — 예전엔 결과 카드가 자체 스크롤
|
|
94
95
|
// 래퍼(.trp-result__scroll)를 하나 더 두어 이중 스크롤이 됐고, 안쪽이 overscroll:contain으로
|
|
@@ -4,9 +4,12 @@ interface WordDataUpdatePopupProps {
|
|
|
4
4
|
title: string;
|
|
5
5
|
progress: WordDataProgress;
|
|
6
6
|
onDone: () => void;
|
|
7
|
+
/** 오류 때 '다시 시도' 단추가 부른다. 없으면 그 자리에 '닫기' 단추가 선다. */
|
|
7
8
|
onRetry?: () => void;
|
|
9
|
+
/** 오류인데 `onRetry` 가 없을 때 '닫기' 단추가 부른다. 없으면 `onDone` 을 부른다. */
|
|
10
|
+
onClose?: () => void;
|
|
8
11
|
inline?: boolean;
|
|
9
12
|
}
|
|
10
13
|
/** @public */
|
|
11
|
-
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, inline }: WordDataUpdatePopupProps): import("react").JSX.Element;
|
|
14
|
+
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, onClose, inline }: WordDataUpdatePopupProps): import("react").JSX.Element;
|
|
12
15
|
export {};
|
|
@@ -37,7 +37,7 @@ function PhaseCard({ icon, label, state, pct, subText }) {
|
|
|
37
37
|
: (subText || pct !== undefined) && (_jsxs("span", { className: "wdup-phase-card__sub body-3", children: [subText, subText && pct !== undefined ? ` · ${pct}%` : pct !== undefined ? `${pct}%` : ''] }))] }), !isPending && (_jsx("div", { className: "wdup-bar", children: _jsx("div", { className: `wdup-bar__fill${isDone ? ' wdup-bar__fill--done' : isError ? ' wdup-bar__fill--error' : ''}${state === 'active' && pct === undefined ? ' wdup-bar__fill--indeterminate' : ''}`, style: { width: pct !== undefined || isDone || isError ? `${isError ? 100 : barPct}%` : undefined } }) }))] })] }));
|
|
38
38
|
}
|
|
39
39
|
/** @public */
|
|
40
|
-
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, inline }) {
|
|
40
|
+
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, onClose, inline }) {
|
|
41
41
|
const { t } = useTranslation();
|
|
42
42
|
const { phase, error, wordCount, imagePage, imageDone, imageTotal, pairPercent } = progress;
|
|
43
43
|
const order = phaseOrder(phase);
|
|
@@ -67,7 +67,12 @@ export default function WordDataUpdatePopup({ title, progress, onDone, onRetry,
|
|
|
67
67
|
return t('wordData.imageCount', { done: formatCount(imageDone), total: formatCount(imageTotal) });
|
|
68
68
|
return undefined;
|
|
69
69
|
}, [imgState, phase, imagePage, imageTotal, imageDone, t]);
|
|
70
|
-
return (_jsx(Popup, { inline: inline, className: "popup--word-data-update", title: title, footer:
|
|
70
|
+
return (_jsx(Popup, { inline: inline, className: "popup--word-data-update", title: title, footer:
|
|
71
|
+
/* 오류인데 다시 시도할 곳이 없으면 '다시 시도' 가 아무 일도 안 하고, 이 팝업은 X 도
|
|
72
|
+
Esc 도 없어 **빠져나갈 길이 없었다**. 그때만 닫기 단추로 바꾼다. */
|
|
73
|
+
isError && onRetry
|
|
71
74
|
? _jsx(Button, { size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
|
|
72
|
-
:
|
|
75
|
+
: isError
|
|
76
|
+
? _jsx(Button, { size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
|
|
77
|
+
: _jsx(Button, { size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
|
|
73
78
|
}
|
|
@@ -26,5 +26,10 @@ export declare function visitItemKey(v: {
|
|
|
26
26
|
hour: string;
|
|
27
27
|
minute: string;
|
|
28
28
|
}): string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
|
|
31
|
+
*
|
|
32
|
+
* 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
|
|
33
|
+
* 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatDuration(sec: number | null | undefined, durationStr?: (m: number, s: number) => string): string;
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* customerDetailUtils.ts
|
|
3
3
|
* CustomerDetailPopup의 순수 표시/포맷 유틸 (상태 비의존).
|
|
4
4
|
*/
|
|
5
|
+
import i18n from 'i18next';
|
|
5
6
|
import { getTodayKst } from '../../utils/date';
|
|
7
|
+
/** i18n 이 아직 안 섰으면(초기화 전 `i18n.t` 는 undefined) 한국어 기본값을 직접 채운다. */
|
|
8
|
+
function 번역됐거나(s, 기본, vars) {
|
|
9
|
+
return typeof s === 'string' && s ? s : 기본.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
|
|
10
|
+
}
|
|
6
11
|
export function getDayOptions(year, month, daySuffix) {
|
|
7
12
|
const days = new Date(Number(year), Number(month), 0).getDate();
|
|
8
13
|
return Array.from({ length: days }, (_, i) => {
|
|
@@ -65,11 +70,23 @@ export function formatId(id) {
|
|
|
65
70
|
export function visitItemKey(v) {
|
|
66
71
|
return `${v.year}-${v.month}-${v.day}T${v.hour}:${v.minute}`;
|
|
67
72
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
73
|
+
/**
|
|
74
|
+
* 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
|
|
75
|
+
*
|
|
76
|
+
* 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
|
|
77
|
+
* 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
|
|
78
|
+
*/
|
|
79
|
+
export function formatDuration(sec, durationStr) {
|
|
70
80
|
if (!sec || sec <= 0)
|
|
71
81
|
return '-';
|
|
72
82
|
const m = Math.floor(sec / 60);
|
|
73
83
|
const s = sec % 60;
|
|
74
|
-
|
|
84
|
+
if (durationStr)
|
|
85
|
+
return durationStr(m, s);
|
|
86
|
+
// 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
|
|
87
|
+
if (m > 0 && s > 0)
|
|
88
|
+
return 번역됐거나(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
|
|
89
|
+
if (m > 0)
|
|
90
|
+
return 번역됐거나(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
|
|
91
|
+
return 번역됐거나(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
|
|
75
92
|
}
|