refactor(공개화면): A01~A09 · B10 · B11 을 공개 화면 틀 · 카드 · 알림 · 배지 · 체크 · 접이 부품으로 (PLAN 553-7)

- A01~A05 hero 다섯 벌 · 구역 제목 복제 CSS 걷고 공개 화면 틀 한 벌(wide 옵션 · 구역 제목 가운데 · 머리 아랫줄은 자리 안 먹는 그림자)
- 카드 = 카드 부품(로그인 · 가입 · 지원 · 약관 · 기능 카드 · 소식 줄 · B11 단계 카드) · 지원 성공 · B10 안내 = 띠 알림 · 태그 = 배지
- A07 약관 날 체크 · 날 details = 체크 + 접이 부품 · A08 날 textarea = 입력 여러 줄 · 빈 소식 = 빈 상태
- 죽은 A07_Register_UI_Page · 쓰이지 않는 CSS 걷음 · 띠 알림 hidden 받음
- 지킴 시험 test_553_7_public_pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:56:42 +09:00
co-authored by Claude Sonnet 5.5
parent 9729892a3d
commit 25adc2f0e7
27 changed files with 338 additions and 988 deletions
+30 -70
View File
@@ -5,12 +5,15 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A01_Home_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createCard/createTag) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createBadge/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createCard, createTag } from "@ui/ui_template_elements";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A01_Home_UI_Style.css";
@@ -49,57 +52,12 @@ function onA01_Home_Explore_Click(): void {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a01-hero";
const inner = document.createElement("div");
inner.className = "a01-hero__inner";
const title = document.createElement("h1");
title.className = "a01-hero__title";
title.textContent = L("A01_Home_Title");
const subtitle = document.createElement("p");
subtitle.className = "a01-hero__subtitle";
subtitle.textContent = L("A01_Home_Hero_Subtitle");
const ctas = document.createElement("div");
ctas.className = "a01-hero__ctas";
ctas.append(
createButton({
label: L("A01_Home_Hero_CtaPrimary"),
variant: "filled",
onClick: onA01_Home_GetStarted_Click,
}),
createButton({
label: L("A01_Home_Hero_CtaSecondary"),
variant: "ghost",
onClick: onA01_Home_Explore_Click,
}),
);
inner.append(title, subtitle, ctas);
hero.append(inner);
return hero;
}
function buildNews(): HTMLElement {
const section = document.createElement("section");
section.className = "a01-section";
const heading = document.createElement("h2");
heading.className = "a01-section__title";
heading.textContent = L("A01_Home_News_SectionTitle");
const list = document.createElement("div");
list.className = "a01-news-list";
for (const item of MOCK_NEWS) {
const row = document.createElement("div");
row.className = "a01-news-row";
const tag = createTag(L("A01_Home_News_Tag"), "accent");
const tag = createBadge(L("A01_Home_News_Tag"), "accent");
const titleEl = document.createElement("span");
titleEl.className = "a01-news-row__title";
titleEl.textContent = item.title;
@@ -107,22 +65,14 @@ function buildNews(): HTMLElement {
dateEl.className = "a01-news-row__date";
dateEl.textContent = item.date;
row.append(tag, titleEl, dateEl);
const row = createCard({ body: [tag, titleEl, dateEl] });
row.classList.add("a01-news-row");
list.append(row);
}
section.append(heading, list);
return section;
return list;
}
function buildFeatures(): HTMLElement {
const section = document.createElement("section");
section.className = "a01-section";
const heading = document.createElement("h2");
heading.className = "a01-section__title";
heading.textContent = L("A01_Home_Features_SectionTitle");
const grid = document.createElement("div");
grid.className = "a01-feature-grid";
@@ -133,24 +83,34 @@ function buildFeatures(): HTMLElement {
];
for (const [titleKey, descKey] of features) {
const desc = document.createElement("p");
desc.className = "a01-feature__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a01-feature-card");
grid.append(card);
}
section.append(heading, grid);
return section;
return grid;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA01Home(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a01-home";
page.append(buildHero(), buildNews(), buildFeatures());
root.append(page);
const frame = createPublicFrame({
title: L("A01_Home_Title"),
lead: L("A01_Home_Hero_Subtitle"),
actions: [
createButton({
label: L("A01_Home_Hero_CtaPrimary"),
variant: "filled",
onClick: onA01_Home_GetStarted_Click,
}),
createButton({
label: L("A01_Home_Hero_CtaSecondary"),
variant: "ghost",
onClick: onA01_Home_Explore_Click,
}),
],
});
frame.addSection(L("A01_Home_News_SectionTitle")).append(buildNews());
frame.addSection(L("A01_Home_Features_SectionTitle")).append(buildFeatures());
root.append(frame.root);
}
+7 -75
View File
@@ -1,66 +1,8 @@
/* =============================================================================
* A01_Home_UI_Style.css
* 홈 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 홈 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a01-home {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a01-hero {
/* 라벤더 그라디언트는 흰 표면 아래에만: 히어로 배경으로 사용 */
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a01-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a01-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 14ch;
}
.a01-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 52ch;
margin: 0;
}
.a01-hero__ctas {
display: flex;
gap: var(--spacing-16);
margin-top: var(--spacing-8);
}
/* --- 공통 섹션 --- */
.a01-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a01-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
}
/* --- News --- */
.a01-news-list {
display: flex;
@@ -68,21 +10,20 @@
gap: var(--spacing-8);
}
.a01-news-row {
display: flex;
padding: var(--spacing-16) var(--spacing-24);
}
.a01-news-row > .ui-card__body {
flex-direction: row;
align-items: center;
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.a01-news-row__title {
flex: 1;
color: var(--color-charcoal);
color: var(--color-text);
font-size: var(--text-body-sm);
}
.a01-news-row__date {
color: var(--color-ash);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
@@ -95,18 +36,9 @@
.a01-feature-card {
min-height: 140px;
}
.a01-feature__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
@media (max-width: 900px) {
.a01-feature-grid {
grid-template-columns: 1fr;
}
.a01-hero__title {
font-size: var(--text-heading);
}
}
+25 -64
View File
@@ -5,12 +5,14 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A02_ProgDetail_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createCard) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A02_ProgDetail_UI_Style.css";
@@ -30,40 +32,7 @@ function onA02_ProgDetail_Start_Click(): void {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a02-hero";
const inner = document.createElement("div");
inner.className = "a02-hero__inner";
const title = document.createElement("h1");
title.className = "a02-hero__title";
title.textContent = L("A02_ProgDetail_Title");
const subtitle = document.createElement("p");
subtitle.className = "a02-hero__subtitle";
subtitle.textContent = L("A02_ProgDetail_Hero_Subtitle");
const cta = createButton({
label: L("A02_ProgDetail_Hero_Cta"),
variant: "filled",
onClick: onA02_ProgDetail_Start_Click,
});
inner.append(title, subtitle, cta);
hero.append(inner);
return hero;
}
function buildWorkflow(): HTMLElement {
const section = document.createElement("section");
section.className = "a02-section";
const heading = document.createElement("h2");
heading.className = "a02-section__title";
heading.textContent = L("A02_ProgDetail_Workflow_SectionTitle");
const list = document.createElement("div");
list.className = "a02-workflow-list";
@@ -77,9 +46,6 @@ function buildWorkflow(): HTMLElement {
];
steps.forEach(([titleKey, descKey], index) => {
const row = document.createElement("div");
row.className = "a02-workflow-row";
const badge = document.createElement("span");
badge.className = "a02-workflow-row__badge";
badge.textContent = String(index + 1);
@@ -91,27 +57,15 @@ function buildWorkflow(): HTMLElement {
stepTitle.className = "a02-workflow-row__title";
stepTitle.textContent = L(titleKey);
const stepDesc = document.createElement("p");
stepDesc.className = "a02-workflow-row__desc";
stepDesc.textContent = L(descKey);
text.append(stepTitle, stepDesc);
row.append(badge, text);
text.append(stepTitle, createText(L(descKey), "muted", 14));
const row = createCard({ body: [badge, text] });
row.classList.add("a02-workflow-row");
list.append(row);
});
section.append(heading, list);
return section;
return list;
}
function buildTech(): HTMLElement {
const section = document.createElement("section");
section.className = "a02-section";
const heading = document.createElement("h2");
heading.className = "a02-section__title";
heading.textContent = L("A02_ProgDetail_Tech_SectionTitle");
const grid = document.createElement("div");
grid.className = "a02-tech-grid";
@@ -122,24 +76,31 @@ function buildTech(): HTMLElement {
];
for (const [titleKey, descKey] of techs) {
const desc = document.createElement("p");
desc.className = "a02-tech__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a02-tech-card");
grid.append(card);
}
section.append(heading, grid);
return section;
return grid;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA02ProgDetail(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a02-prog-detail";
page.append(buildHero(), buildWorkflow(), buildTech());
root.append(page);
const frame = createPublicFrame({
title: L("A02_ProgDetail_Title"),
lead: L("A02_ProgDetail_Hero_Subtitle"),
actions: [
createButton({
label: L("A02_ProgDetail_Hero_Cta"),
variant: "filled",
onClick: onA02_ProgDetail_Start_Click,
}),
],
wide: true,
});
frame.root.classList.add("a02-prog-detail");
frame.addSection(L("A02_ProgDetail_Workflow_SectionTitle")).append(buildWorkflow());
frame.addSection(L("A02_ProgDetail_Tech_SectionTitle")).append(buildTech());
root.append(frame.root);
}
+7 -73
View File
@@ -1,58 +1,11 @@
/* =============================================================================
* A02_ProgDetail_UI_Style.css
* 프로그램 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 프로그램 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a02-prog-detail {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a02-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a02-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a02-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a02-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a02-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a02-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
/* 단추 하나뿐인 머리 — 단추 줄 위 여백 없음 */
.a02-prog-detail .ui-public-frame__actions {
margin-top: 0;
}
/* --- Workflow (번호 배지 + 텍스트) --- */
@@ -61,14 +14,10 @@
flex-direction: column;
gap: var(--spacing-16);
}
.a02-workflow-row {
display: flex;
.a02-workflow-row > .ui-card__body {
flex-direction: row;
align-items: flex-start;
gap: var(--spacing-24);
padding: var(--spacing-24);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.a02-workflow-row__badge {
flex-shrink: 0;
@@ -90,15 +39,9 @@
}
.a02-workflow-row__title {
font-size: var(--text-body);
color: var(--color-charcoal);
color: var(--color-text);
margin: 0;
}
.a02-workflow-row__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
/* --- Tech --- */
.a02-tech-grid {
@@ -109,18 +52,9 @@
.a02-tech-card {
min-height: 140px;
}
.a02-tech__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
@media (max-width: 900px) {
.a02-tech-grid {
grid-template-columns: 1fr;
}
.a02-hero__title {
font-size: var(--text-heading);
}
}
+16 -59
View File
@@ -5,11 +5,13 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A03_CompDetail_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createCard) 우선 사용.
* - 공통 부품(createPublicFrame/createCard) 우선 사용.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import "./A03_CompDetail_UI_Style.css";
/** locale 헬퍼 */
@@ -20,50 +22,14 @@ function L(key: keyof typeof ui_locales): string {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a03-hero";
const inner = document.createElement("div");
inner.className = "a03-hero__inner";
const title = document.createElement("h1");
title.className = "a03-hero__title";
title.textContent = L("A03_CompDetail_Title");
const subtitle = document.createElement("p");
subtitle.className = "a03-hero__subtitle";
subtitle.textContent = L("A03_CompDetail_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildMission(): HTMLElement {
const section = document.createElement("section");
section.className = "a03-section";
const heading = document.createElement("h2");
heading.className = "a03-section__title";
heading.textContent = L("A03_CompDetail_Mission_SectionTitle");
const body = document.createElement("p");
body.className = "a03-mission__body";
body.textContent = L("A03_CompDetail_Mission_Body");
section.append(heading, body);
return section;
return body;
}
function buildValues(): HTMLElement {
const section = document.createElement("section");
section.className = "a03-section";
const heading = document.createElement("h2");
heading.className = "a03-section__title";
heading.textContent = L("A03_CompDetail_Value_SectionTitle");
const grid = document.createElement("div");
grid.className = "a03-value-grid";
@@ -74,40 +40,31 @@ function buildValues(): HTMLElement {
];
for (const [titleKey, descKey] of values) {
const desc = document.createElement("p");
desc.className = "a03-value__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a03-value-card");
grid.append(card);
}
section.append(heading, grid);
return section;
return grid;
}
function buildContact(): HTMLElement {
const section = document.createElement("section");
section.className = "a03-section";
const heading = document.createElement("h2");
heading.className = "a03-section__title";
heading.textContent = L("A03_CompDetail_Contact_SectionTitle");
const email = document.createElement("p");
email.className = "a03-contact__email";
email.textContent = L("A03_CompDetail_Contact_Email");
section.append(heading, email);
return section;
return email;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA03CompDetail(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a03-comp-detail";
page.append(buildHero(), buildMission(), buildValues(), buildContact());
root.append(page);
const frame = createPublicFrame({
title: L("A03_CompDetail_Title"),
lead: L("A03_CompDetail_Hero_Subtitle"),
wide: true,
});
frame.addSection(L("A03_CompDetail_Mission_SectionTitle")).append(buildMission());
frame.addSection(L("A03_CompDetail_Value_SectionTitle")).append(buildValues());
frame.addSection(L("A03_CompDetail_Contact_SectionTitle")).append(buildContact());
root.append(frame.root);
}
+3 -64
View File
@@ -1,66 +1,14 @@
/* =============================================================================
* A03_CompDetail_UI_Style.css
* 회사 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 회사 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a03-comp-detail {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a03-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a03-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a03-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a03-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a03-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a03-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
}
/* --- Mission --- */
.a03-mission__body {
margin: 0 auto;
max-width: 60ch;
text-align: center;
color: var(--color-slate);
color: var(--color-text-secondary);
font-size: var(--text-body);
line-height: var(--leading-body);
}
@@ -74,18 +22,12 @@
.a03-value-card {
min-height: 140px;
}
.a03-value__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
/* --- Contact --- */
.a03-contact__email {
margin: 0;
text-align: center;
color: var(--color-charcoal);
color: var(--color-text);
font-size: var(--text-body);
}
@@ -93,7 +35,4 @@
.a03-value-grid {
grid-template-columns: 1fr;
}
.a03-hero__title {
font-size: var(--text-heading);
}
}
+18 -46
View File
@@ -5,12 +5,15 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A04_NewsHistory_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createTag) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createBadge) 우선 사용.
* - Mock 데이터는 나중에 GET /api/a04/history로 교체.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createTag, type TagVariant } from "@ui/ui_template_elements";
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createCard } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import "./A04_NewsHistory_UI_Style.css";
/** locale 헬퍼 */
@@ -38,7 +41,7 @@ const MOCK_HISTORY: HistoryItem[] = [
];
/** 이력 종류별 태그 변형/라벨 매핑 */
const KIND_META: Record<HistoryKind, { variant: TagVariant; labelKey: keyof typeof ui_locales }> = {
const KIND_META: Record<HistoryKind, { variant: BadgeTone; labelKey: keyof typeof ui_locales }> = {
feature: { variant: "accent", labelKey: "A04_NewsHistory_Tag_Feature" },
fix: { variant: "success", labelKey: "A04_NewsHistory_Tag_Fix" },
notice: { variant: "neutral", labelKey: "A04_NewsHistory_Tag_Notice" },
@@ -47,47 +50,14 @@ const KIND_META: Record<HistoryKind, { variant: TagVariant; labelKey: keyof type
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a04-hero";
const inner = document.createElement("div");
inner.className = "a04-hero__inner";
const title = document.createElement("h1");
title.className = "a04-hero__title";
title.textContent = L("A04_NewsHistory_Title");
const subtitle = document.createElement("p");
subtitle.className = "a04-hero__subtitle";
subtitle.textContent = L("A04_NewsHistory_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildTimeline(): HTMLElement {
const section = document.createElement("section");
section.className = "a04-section";
if (MOCK_HISTORY.length === 0) return createEmpty({ text: L("A04_NewsHistory_Empty") });
const list = document.createElement("div");
list.className = "a04-timeline";
if (MOCK_HISTORY.length === 0) {
const empty = document.createElement("p");
empty.className = "a04-timeline__empty";
empty.textContent = L("A04_NewsHistory_Empty");
section.append(empty);
return section;
}
for (const item of MOCK_HISTORY) {
const meta = KIND_META[item.kind];
const row = document.createElement("div");
row.className = "a04-timeline-row";
const tag = createTag(L(meta.labelKey), meta.variant);
const tag = createBadge(L(meta.labelKey), meta.variant);
const titleEl = document.createElement("span");
titleEl.className = "a04-timeline-row__title";
@@ -97,20 +67,22 @@ function buildTimeline(): HTMLElement {
dateEl.className = "a04-timeline-row__date";
dateEl.textContent = item.date;
row.append(tag, titleEl, dateEl);
const row = createCard({ body: [tag, titleEl, dateEl] });
row.classList.add("a04-timeline-row");
list.append(row);
}
section.append(list);
return section;
return list;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA04NewsHistory(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a04-news-history";
page.append(buildHero(), buildTimeline());
root.append(page);
const frame = createPublicFrame({
title: L("A04_NewsHistory_Title"),
lead: L("A04_NewsHistory_Hero_Subtitle"),
wide: true,
});
frame.addSection().append(buildTimeline());
root.append(frame.root);
}
+7 -65
View File
@@ -1,54 +1,8 @@
/* =============================================================================
* A04_NewsHistory_UI_Style.css
* 최신소식/개선이력 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 최신소식 · 개선 이력 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a04-news-history {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a04-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a04-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a04-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a04-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a04-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
/* --- Timeline --- */
.a04-timeline {
display: flex;
@@ -56,31 +10,19 @@
gap: var(--spacing-8);
}
.a04-timeline-row {
display: flex;
padding: var(--spacing-16) var(--spacing-24);
}
.a04-timeline-row > .ui-card__body {
flex-direction: row;
align-items: center;
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.a04-timeline-row__title {
flex: 1;
color: var(--color-charcoal);
color: var(--color-text);
font-size: var(--text-body-sm);
}
.a04-timeline-row__date {
color: var(--color-ash);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.a04-timeline__empty {
text-align: center;
color: var(--color-slate);
font-size: var(--text-body);
}
@media (max-width: 900px) {
.a04-hero__title {
font-size: var(--text-heading);
}
}
+21 -53
View File
@@ -5,12 +5,14 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A05_EduDetail_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createCard) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A05_EduDetail_UI_Style.css";
@@ -30,34 +32,7 @@ function onA05_EduDetail_Inquiry_Click(): void {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a05-hero";
const inner = document.createElement("div");
inner.className = "a05-hero__inner";
const title = document.createElement("h1");
title.className = "a05-hero__title";
title.textContent = L("A05_EduDetail_Title");
const subtitle = document.createElement("p");
subtitle.className = "a05-hero__subtitle";
subtitle.textContent = L("A05_EduDetail_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildCourses(): HTMLElement {
const section = document.createElement("section");
section.className = "a05-section";
const heading = document.createElement("h2");
heading.className = "a05-section__title";
heading.textContent = L("A05_EduDetail_Course_SectionTitle");
const grid = document.createElement("div");
grid.className = "a05-course-grid";
@@ -68,38 +43,31 @@ function buildCourses(): HTMLElement {
];
for (const [titleKey, descKey] of courses) {
const desc = document.createElement("p");
desc.className = "a05-course__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a05-course-card");
grid.append(card);
}
section.append(heading, grid);
return section;
}
function buildCta(): HTMLElement {
const section = document.createElement("section");
section.className = "a05-section a05-cta";
section.append(
createButton({
label: L("A05_EduDetail_Cta"),
variant: "filled",
onClick: onA05_EduDetail_Inquiry_Click,
}),
);
return section;
return grid;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA05EduDetail(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a05-edu-detail";
page.append(buildHero(), buildCourses(), buildCta());
root.append(page);
const frame = createPublicFrame({
title: L("A05_EduDetail_Title"),
lead: L("A05_EduDetail_Hero_Subtitle"),
wide: true,
});
frame.addSection(L("A05_EduDetail_Course_SectionTitle")).append(buildCourses());
const cta = frame.addSection();
cta.classList.add("a05-cta");
cta.append(
createButton({
label: L("A05_EduDetail_Cta"),
variant: "filled",
onClick: onA05_EduDetail_Inquiry_Click,
}),
);
root.append(frame.root);
}
+1 -62
View File
@@ -1,60 +1,8 @@
/* =============================================================================
* A05_EduDetail_UI_Style.css
* 교육 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 교육 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a05-edu-detail {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a05-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a05-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a05-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a05-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a05-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a05-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
}
/* --- Courses --- */
.a05-course-grid {
display: grid;
@@ -64,12 +12,6 @@
.a05-course-card {
min-height: 140px;
}
.a05-course__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
/* --- CTA --- */
.a05-cta {
@@ -80,7 +22,4 @@
.a05-course-grid {
grid-template-columns: 1fr;
}
.a05-hero__title {
font-size: var(--text-heading);
}
}
+3 -3
View File
@@ -2,6 +2,7 @@ import { ROUTES } from "@config/config_frontend";
import { isBlank, isValidEmail } from "@util/common_util_validate";
import {
createButton,
createCard,
createInputField,
hideLoadingOverlay,
showLoadingOverlay,
@@ -67,8 +68,6 @@ function clearOtpPending(): void {
export function renderA06Login(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a06-login";
const card = document.createElement("div");
card.className = "a06-login__card";
const title = document.createElement("h1");
title.className = "a06-login__title";
title.textContent = L("A06_Login_Title");
@@ -215,7 +214,8 @@ export function renderA06Login(root: HTMLElement): void {
variant: "ghost",
onClick: () => navigateTo(ROUTES.A07_REGISTER),
});
card.append(title, form, register);
const card = createCard({ body: [title, form, register] });
card.classList.add("a06-login__card");
page.append(card);
root.append(page);
+2 -22
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* A06_Login_UI_Style.css
* 로그인 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 로그인 페이지 고유 배치 — 카드 겉은 공용 부품(카드)이 냄.
* ========================================================================== */
.a06-login {
@@ -13,29 +13,16 @@
.a06-login__card {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-40);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a06-login__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
color: var(--color-text);
text-align: center;
margin: 0;
}
.a06-login__subtitle {
font-size: var(--text-body-sm);
color: var(--color-slate);
text-align: center;
margin: 0 0 var(--spacing-8);
}
.a06-login__form {
display: flex;
@@ -50,10 +37,3 @@
.a06-login__otp-actions > .ui-btn {
flex: 1;
}
.a06-login__submit {
width: 100%;
margin-top: var(--spacing-8);
}
.a06-login__link {
width: 100%;
}
+17 -24
View File
@@ -2,11 +2,14 @@ import { ROUTES } from "@config/config_frontend";
import { hasMinLength, isBlank, isValidEmail } from "@util/common_util_validate";
import {
createButton,
createCard,
createInputField,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { navigateTo } from "../A00_Common/router";
import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetch";
@@ -22,38 +25,29 @@ interface AgreementField {
function checkboxWithAccordion(label: string, contentText: string): AgreementField {
const container = document.createElement("div");
container.className = "a07-register__agreement";
container.className = "ui-collapsible is-collapsed a07-register__agreement";
const summary = document.createElement("div");
summary.className = "a07-register__agreement-summary";
summary.className = "ui-collapsible__title a07-register__agreement-summary";
const input = document.createElement("input");
input.type = "checkbox";
const box = createCheckbox("");
// 체크 상자를 누를 땐 접힘이 바뀌지 않게 — 줄의 나머지를 누르면 약관이 펴짐
box.root.addEventListener("click", (e) => e.stopPropagation());
const labelSpan = document.createElement("span");
labelSpan.className = "a07-register__agreement-label";
labelSpan.textContent = label;
summary.append(input, labelSpan);
summary.append(box.root, labelSpan);
const details = document.createElement("details");
details.className = "a07-register__agreement-details";
const content = document.createElement("div");
content.className = "a07-register__agreement-content";
content.textContent = contentText;
const detailsContent = document.createElement("div");
detailsContent.className = "a07-register__agreement-content";
detailsContent.textContent = contentText;
container.append(summary, content);
attachCollapsible(container);
details.appendChild(detailsContent);
summary.addEventListener("click", (e) => {
if (e.target !== input) {
details.open = !details.open;
}
});
container.append(summary, details);
return { root: container, input };
return { root: container, input: box.input };
}
async function loadAgreementText(filename: string): Promise<string> {
@@ -69,8 +63,6 @@ async function loadAgreementText(filename: string): Promise<string> {
export function renderA07Register(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a07-register";
const card = document.createElement("div");
card.className = "a07-register__card";
const title = document.createElement("h1");
title.className = "a07-register__title";
title.textContent = L("A07_Register_Title");
@@ -210,7 +202,8 @@ export function renderA07Register(root: HTMLElement): void {
variant: "ghost",
onClick: () => navigateTo(ROUTES.A06_LOGIN),
});
card.append(title, form, login);
const card = createCard({ body: [title, form, login] });
card.classList.add("a07-register__card");
page.append(card);
root.append(page);
});
-141
View File
@@ -1,141 +0,0 @@
/* =============================================================================
* A07_Register_UI_Page.ts
* 로그인 전 07: 회원가입 (회사명 + 이름 + 이메일 + 비밀번호 폼)
*
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A07_Register_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createInputField) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { isBlank, isValidEmail, hasMinLength } from "@util/common_util_validate";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A07_Register_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 비밀번호 최소 길이 */
const PASSWORD_MIN_LENGTH = 8;
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA07Register(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a07-register";
const card = document.createElement("div");
card.className = "a07-register__card";
const title = document.createElement("h1");
title.className = "a07-register__title";
title.textContent = L("A07_Register_Title");
const subtitle = document.createElement("p");
subtitle.className = "a07-register__subtitle";
subtitle.textContent = L("A07_Register_Subtitle");
// 입력 필드
const companyField = createInputField({
label: L("A07_Register_Field_Company"),
placeholder: L("A07_Register_Field_Company_Placeholder"),
type: "text",
required: true,
});
const nameField = createInputField({
label: L("A07_Register_Field_Name"),
placeholder: L("A07_Register_Field_Name_Placeholder"),
type: "text",
required: true,
});
const emailField = createInputField({
label: L("A07_Register_Field_Email"),
placeholder: L("A07_Register_Field_Email_Placeholder"),
type: "email",
required: true,
});
const passwordField = createInputField({
label: L("A07_Register_Field_Password"),
placeholder: L("A07_Register_Field_Password_Placeholder"),
type: "password",
required: true,
});
const form = document.createElement("form");
form.className = "a07-register__form";
form.noValidate = true;
const submitBtn = createButton({
label: L("A07_Register_Submit"),
variant: "filled",
type: "submit",
});
submitBtn.classList.add("a07-register__submit");
/* 이벤트 핸들러: 제출 시 1차 유효성 검사 */
function onA07_Register_Submit_Click(event: Event): void {
event.preventDefault();
[companyField, nameField, emailField, passwordField].forEach((f) => f.setError());
const company = companyField.input.value;
const name = nameField.input.value;
const email = emailField.input.value;
const password = passwordField.input.value;
// 필수값 검사
let hasError = false;
if (isBlank(company)) {
companyField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (isBlank(name)) {
nameField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (isBlank(email)) {
emailField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (isBlank(password)) {
passwordField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (hasError) return;
// 형식 검사
if (!isValidEmail(email)) {
emailField.setError(L("A07_Register_Error_Email"));
return;
}
if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) {
passwordField.setError(L("A07_Register_Error_Password"));
return;
}
// TODO: POST /api/a07/register 연동. 성공 시 로그인 페이지로.
navigateTo(ROUTES.A06_LOGIN);
}
form.addEventListener("submit", onA07_Register_Submit_Click);
form.append(companyField.root, nameField.root, emailField.root, passwordField.root, submitBtn);
// 로그인 링크
const toLogin = createButton({
label: L("A07_Register_ToLogin"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.A06_LOGIN),
});
toLogin.classList.add("a07-register__link");
card.append(title, subtitle, form, toLogin);
page.append(card);
root.append(page);
}
+5 -46
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* A07_Register_UI_Style.css
* 회원가입 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 회원가입 페이지 고유 배치 — 카드 · 체크 · 접이 겉은 공용 부품이 냄.
* ========================================================================== */
.a07-register {
@@ -13,49 +13,22 @@
.a07-register__card {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-40);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a07-register__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
color: var(--color-text);
text-align: center;
margin: 0;
}
.a07-register__subtitle {
font-size: var(--text-body-sm);
color: var(--color-slate);
text-align: center;
margin: 0 0 var(--spacing-8);
}
.a07-register__form {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.a07-register__submit {
width: 100%;
margin-top: var(--spacing-8);
}
.a07-register__link {
width: 100%;
}
.a07-register__check {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text);
font-size: var(--text-body-sm);
}
.a07-register__agreement {
border: 1px solid var(--color-border);
@@ -64,17 +37,9 @@
}
.a07-register__agreement-summary {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
background-color: var(--color-canvas);
cursor: pointer;
user-select: none;
}
.a07-register__agreement-summary input[type="checkbox"] {
flex-shrink: 0;
}
.a07-register__agreement-label {
@@ -83,20 +48,14 @@
color: var(--color-text);
}
.a07-register__agreement-details {
border-top: 1px solid var(--color-border);
}
.a07-register__agreement-details[open] {
padding: var(--spacing-12) var(--spacing-16);
}
.a07-register__agreement-content {
max-height: 200px;
overflow-y: auto;
padding: var(--spacing-12) var(--spacing-16);
border-top: 1px solid var(--color-border);
white-space: pre-wrap;
word-break: break-word;
font-size: var(--text-body-sm);
color: var(--color-slate);
color: var(--color-text-secondary);
line-height: var(--leading-relaxed);
}
+13 -57
View File
@@ -5,13 +5,14 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A08_Support_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createInputField) 우선 사용.
* - 공통 부품(createCard/createBanner/createInputField/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, createCard, createInputField } from "@ui/ui_template_elements";
import { isBlank, isValidEmail } from "@util/common_util_validate";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import "./A08_Support_UI_Style.css";
@@ -21,48 +22,6 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 문의내용 textarea 핸들 (createInputField와 유사한 형태로 에러 슬롯 포함) */
interface TextAreaHandle {
root: HTMLDivElement;
textarea: HTMLTextAreaElement;
setError: (msg?: string) => void;
}
/** 여러 줄 입력용 textarea 필드 생성 (공통 .ui-field/.ui-input 스타일 상속). */
function createTextAreaField(label: string, placeholder: string): TextAreaHandle {
const root = document.createElement("div");
root.className = "ui-field";
const labelEl = document.createElement("label");
labelEl.className = "ui-field__label";
labelEl.textContent = label;
const textarea = document.createElement("textarea");
textarea.className = "ui-input a08-support__textarea";
textarea.placeholder = placeholder;
textarea.rows = 6;
const errorSlot = document.createElement("span");
errorSlot.className = "ui-field__error";
errorSlot.setAttribute("role", "alert");
root.append(labelEl, textarea, errorSlot);
const setError = (msg?: string): void => {
if (msg) {
textarea.classList.add("ui-input--error");
errorSlot.textContent = msg;
errorSlot.classList.add("is-visible");
} else {
textarea.classList.remove("ui-input--error");
errorSlot.textContent = "";
errorSlot.classList.remove("is-visible");
}
};
return { root, textarea, setError };
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
@@ -70,9 +29,6 @@ export function renderA08Support(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a08-support";
const card = document.createElement("div");
card.className = "a08-support__card";
const title = document.createElement("h1");
title.className = "a08-support__title";
title.textContent = L("A08_Support_Title");
@@ -82,9 +38,7 @@ export function renderA08Support(root: HTMLElement): void {
subtitle.textContent = L("A08_Support_Subtitle");
// 성공 메시지 슬롯 (제출 성공 시 표시)
const successMsg = document.createElement("p");
successMsg.className = "a08-support__success";
successMsg.textContent = L("A08_Support_Success");
const successMsg = createBanner({ tone: "success", text: L("A08_Support_Success") });
successMsg.hidden = true;
// 입력 필드
@@ -113,10 +67,11 @@ export function renderA08Support(root: HTMLElement): void {
type: "text",
required: true,
});
const messageField = createTextAreaField(
L("A08_Support_Field_Message"),
L("A08_Support_Field_Message_Placeholder"),
);
const messageField = createInputField({
label: L("A08_Support_Field_Message"),
placeholder: L("A08_Support_Field_Message_Placeholder"),
multiline: 6,
});
const form = document.createElement("form");
form.className = "a08-support__form";
@@ -142,7 +97,7 @@ export function renderA08Support(root: HTMLElement): void {
const email = emailField.input.value;
const phone = phoneField.input.value;
const subject = subjectField.input.value;
const message = messageField.textarea.value;
const message = messageField.input.value;
// 필수값 검사
let hasError = false;
@@ -184,7 +139,7 @@ export function renderA08Support(root: HTMLElement): void {
throw new Error(error.detail ?? "요청 실패");
}
form.reset();
messageField.textarea.value = "";
messageField.input.value = "";
successMsg.hidden = false;
} catch (err) {
const msg = err instanceof Error ? err.message : "오류 발생";
@@ -203,7 +158,8 @@ export function renderA08Support(root: HTMLElement): void {
submitBtn,
);
card.append(title, subtitle, successMsg, form);
const card = createCard({ body: [title, subtitle, successMsg, form] });
card.classList.add("a08-support__card");
page.append(card);
root.append(page);
+3 -25
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* A08_Support_UI_Style.css
* 기술지원 요청 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 기술지원 요청 페이지 고유 배치 — 카드 · 알림 · 칸 겉은 공용 부품이 냄.
* ========================================================================== */
.a08-support {
@@ -13,50 +13,28 @@
.a08-support__card {
width: 100%;
max-width: 520px;
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-40);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a08-support__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
color: var(--color-text);
text-align: center;
margin: 0;
}
.a08-support__subtitle {
font-size: var(--text-body-sm);
color: var(--color-slate);
color: var(--color-text-secondary);
text-align: center;
margin: 0 0 var(--spacing-8);
}
.a08-support__success {
margin: 0;
padding: var(--spacing-16);
text-align: center;
color: var(--color-success);
background-color: var(--color-mist-violet);
border-radius: var(--radius-cards);
font-size: var(--text-body-sm);
}
.a08-support__form {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.a08-support__textarea {
resize: vertical;
min-height: 120px;
font-family: inherit;
}
.a08-support__submit {
width: 100%;
margin-top: var(--spacing-8);
+5 -4
View File
@@ -1,3 +1,4 @@
import { createCard } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { SECURITY_TERMS_VERSION, securityTerms } from "./A09_Security_Terms";
import "./A09_Security_UI_Style.css";
@@ -5,13 +6,11 @@ import "./A09_Security_UI_Style.css";
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
export function renderA09Security(root: HTMLElement): void {
const page = document.createElement("article");
page.className = "a09-security";
const title = document.createElement("h1");
title.textContent = L("A09_Security_Title");
const version = document.createElement("p");
version.textContent = `${L("A09_Security_Version")} ${SECURITY_TERMS_VERSION}`;
page.append(title, version);
const blocks: HTMLElement[] = [title, version];
const sections = [
["A09_Security_Terms", securityTerms.terms],
["A09_Security_Privacy", securityTerms.privacy],
@@ -24,7 +23,9 @@ export function renderA09Security(root: HTMLElement): void {
const paragraph = document.createElement("p");
paragraph.textContent = body[currentLanguageIndex];
section.append(heading, paragraph);
page.append(section);
blocks.push(section);
}
const page = createCard({ body: blocks });
page.classList.add("a09-security");
root.append(page);
}
+5 -4
View File
@@ -1,12 +1,13 @@
/* 약관 문서 — 카드 겉은 공용 부품(카드) · 여기는 폭 · 여백 · 글 흐름만 */
.a09-security {
max-width: 800px;
margin: var(--spacing-64) auto;
padding: var(--spacing-40);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a09-security > .ui-card__body {
display: block;
}
.a09-security section {
+5 -5
View File
@@ -1,4 +1,6 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_elements";
import { section, table, text } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -13,15 +15,13 @@ export function renderB10Payment(root: HTMLElement): void {
invoiceBody.className = "b10-payment__summary";
const invoiceDescription = document.createElement("p");
invoiceDescription.textContent = L("B10_Payment_Invoice_Description");
invoiceBody.append(invoiceDescription, createTag(L("B10_Payment_Invoice_Status"), "neutral"));
invoiceBody.append(invoiceDescription, createBadge(L("B10_Payment_Invoice_Status"), "neutral"));
const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [
createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }),
]);
const depositNote = document.createElement("p");
depositNote.className = "b10-payment__note";
depositNote.textContent = L("B10_Payment_Deposit_Note");
const depositNote = createBanner({ tone: "info", text: L("B10_Payment_Deposit_Note") });
const depositBody = document.createElement("div");
depositBody.className = "b10-payment__summary";
depositBody.append(
-8
View File
@@ -4,11 +4,3 @@
gap: var(--spacing-16);
color: var(--color-text-body);
}
.b10-payment__note {
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
+10 -5
View File
@@ -1,4 +1,5 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createCard } from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -9,8 +10,9 @@ function L(key: keyof typeof ui_locales): string {
}
function buildStatusFlow(): HTMLElement {
const flow = document.createElement("ol");
const flow = document.createElement("div");
flow.className = "b11-status__flow";
flow.setAttribute("role", "list");
const steps = [
L("B11_Status_Step_Request"),
L("B11_Status_Step_Issue"),
@@ -18,11 +20,14 @@ function buildStatusFlow(): HTMLElement {
L("B11_Status_Step_Complete"),
];
for (const label of steps) {
const item = document.createElement("li");
item.className = "b11-status__step";
const title = document.createElement("strong");
title.textContent = label;
item.append(title, createTag(L("B11_Status_NotStarted"), "neutral"));
const item = createCard({
body: [title, createBadge(L("B11_Status_NotStarted"), "neutral")],
pad: 16,
});
item.classList.add("b11-status__step");
item.setAttribute("role", "listitem");
flow.append(item);
}
return flow;
+1 -11
View File
@@ -2,20 +2,10 @@
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-16);
margin: 0;
padding: 0;
list-style: none;
}
.b11-status__step {
display: flex;
flex-direction: column;
.b11-status__step > .ui-card__body {
gap: var(--spacing-12, var(--spacing-8));
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-body);
}
.b11-status__download {
+117
View File
@@ -0,0 +1,117 @@
"""553-7 공개 화면(A01~A09) · 로그인 · B10 · B11 부품 옮김 지킴 시험 — 서버 · 화면 없이 소스로.
· A01~A05 = 공개 화면 틀(createPublicFrame) 한 벌 — 페이지마다 hero · section 복제 CSS 없음
· 카드 = createCard · 알림 = createBanner · 배지 = createBadge · 여러 줄 = createInputField(multiline)
· 페이지 CSS 에 날 색(#hex · rgb · 옛 보라 이름) 없음 · 죽은 A07_Register_UI_Page 없음
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
PUBLIC_PAGES = {
"A01_Home/A01_Home_UI_Page.ts": ("A01_Home/A01_Home_UI_Style.css", "a01"),
"A02_ProgDetail/A02_ProgDetail_UI_Page.ts": (
"A02_ProgDetail/A02_ProgDetail_UI_Style.css",
"a02",
),
"A03_CompDetail/A03_CompDetail_UI_Page.ts": (
"A03_CompDetail/A03_CompDetail_UI_Style.css",
"a03",
),
"A04_NewsHistory/A04_NewsHistory_UI_Page.ts": (
"A04_NewsHistory/A04_NewsHistory_UI_Style.css",
"a04",
),
"A05_EduDetail/A05_EduDetail_UI_Page.ts": ("A05_EduDetail/A05_EduDetail_UI_Style.css", "a05"),
}
CARD_PAGES = [
"A06_Login/A06_Login_UI_Auth_Page.ts",
"A07_Register/A07_Register_UI_Auth_Page.ts",
"A08_Support/A08_Support_UI_Page.ts",
"A09_Security/A09_Security_UI_Page.ts",
]
CSS_FILES = [css for css, _ in PUBLIC_PAGES.values()] + [
"A06_Login/A06_Login_UI_Style.css",
"A07_Register/A07_Register_UI_Style.css",
"A08_Support/A08_Support_UI_Style.css",
"A09_Security/A09_Security_UI_Style.css",
"B10_Payment/B10_Payment_UI_Style.css",
"B11_Status/B11_Status_UI_Style.css",
]
RAW_COLOR = re.compile(
r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|var\(--(?:b01|draw)-|"
r"var\(--color-(?:deep-iris|mist-violet|slate|charcoal|ash|plum-velvet)\)"
)
def _text(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _no_comments(css: str) -> str:
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
@pytest.mark.parametrize("page", PUBLIC_PAGES)
def test_public_page_uses_frame(page: str) -> None:
src = _text(page)
assert "createPublicFrame" in src
for tag in ("hero", "__inner", "__subtitle", "__ctas"):
assert tag not in src, f"{page}: 손 hero 조각 {tag} 남음"
@pytest.mark.parametrize("page", PUBLIC_PAGES)
def test_public_css_has_no_hero_copy(page: str) -> None:
css = _no_comments(_text(PUBLIC_PAGES[page][0]))
prefix = PUBLIC_PAGES[page][1]
assert f".{prefix}-hero" not in css
assert f".{prefix}-section" not in css
@pytest.mark.parametrize("page", CARD_PAGES)
def test_card_pages_use_card_part(page: str) -> None:
assert "createCard" in _text(page)
@pytest.mark.parametrize("css", CSS_FILES)
def test_page_css_no_raw_color(css: str) -> None:
assert not RAW_COLOR.search(_no_comments(_text(css)))
def test_a07_agreement_uses_checkbox_and_collapsible() -> None:
src = _text("A07_Register/A07_Register_UI_Auth_Page.ts")
assert "createCheckbox" in src and "attachCollapsible" in src
assert "<details" not in src and 'createElement("details")' not in src
assert 'type = "checkbox"' not in src
def test_a08_uses_banner_and_multiline_input() -> None:
src = _text("A08_Support/A08_Support_UI_Page.ts")
assert "createBanner" in src and "multiline" in src
assert "createTextAreaField" not in src and 'createElement("textarea")' not in src
def test_b10_b11_use_parts() -> None:
b10 = _text("B10_Payment/B10_Payment_UI_Page.ts")
b11 = _text("B11_Status/B11_Status_UI_Page.ts")
assert "createBanner" in b10 and "createBadge" in b10 and "createTag" not in b10
assert "createCard" in b11 and "createBadge" in b11 and "createTag" not in b11
assert ".b10-payment__note" not in _text("B10_Payment/B10_Payment_UI_Style.css")
assert ".b11-status__step {" not in _text("B11_Status/B11_Status_UI_Style.css")
def test_dead_register_page_gone() -> None:
assert not (ROOT / "A07_Register/A07_Register_UI_Page.ts").exists()
def test_public_frame_wide_option() -> None:
ts = _text("ui_template/ui_template_public_layout.ts")
css = _text("ui_template/ui_template_public_layout.css")
assert "wide?: boolean" in ts
assert "[data-wide] .ui-public-frame__title" in css
+3
View File
@@ -14,6 +14,9 @@
font-size: 13px;
line-height: 1.45;
}
.ui-banner[hidden] {
display: none;
}
.ui-banner--warning {
--banner-tone: var(--color-warning);
}
+10 -1
View File
@@ -8,7 +8,7 @@
.ui-public-frame__hero {
padding: var(--spacing-96) var(--spacing-32);
border-bottom: 1px solid var(--color-border);
box-shadow: inset 0 -1px 0 var(--color-border);
background: linear-gradient(to bottom, var(--color-accent-soft), var(--color-canvas));
}
@@ -35,6 +35,14 @@
line-height: var(--leading-heading-lg);
}
.ui-public-frame__hero[data-wide] .ui-public-frame__title {
max-width: 18ch;
}
.ui-public-frame__hero[data-wide] .ui-public-frame__lead {
max-width: 56ch;
}
.ui-public-frame__lead {
max-width: 52ch;
margin: 0;
@@ -66,6 +74,7 @@
color: var(--color-text);
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
text-align: center;
}
.ui-public-frame__section-body {
+4 -1
View File
@@ -3,7 +3,7 @@
* 공개 화면 틀 `public-frame`(552-4 틀) — 로그인 전 공개 페이지(A01 ~ A09) 몸통: 머리 띠(hero) · 구역 · 본문.
* 꼴 = 머리 띠(제목 · 설명 · 주 단추 줄 · 가운데 / 왼쪽 맞춤) · 구역(제목 + 몸) 여럿
* 공개 머리(로고 · 메뉴)와 바닥글(`.app-footer`)은 앱 셸 몫 — 이 틀은 그 사이 본문만
* 크기 = 폭 = --page-max-width · 제목 = --text-heading-lg(좁으면 --text-heading) · 구역 제목 = --text-heading-sm
* 크기 = 폭 = --page-max-width · 제목 = --text-heading-lg(좁으면 --text-heading · `wide` = 18 자) · 구역 제목 = --text-heading-sm(가운데)
* 상태 = 없음
* 쓰는 법 = const f = createPublicFrame({ title: "임도 설계", lead: "…", actions: [btn] }); f.addSection("기능").append(grid)
* 대신하는 옛 변종 = A01 ~ A05 `.a0N-hero` · `__inner` · `__title` · `__subtitle` · `__ctas` 다섯 벌 · `.a0N-section(__title)`
@@ -17,6 +17,8 @@ export interface PublicFrameOptions {
actions?: HTMLElement[];
/** 머리 띠 맞춤 — 기본 가운데 */
align?: "center" | "start";
/** 제목 18 자 · 설명 56 자 폭(기본 14 · 52) */
wide?: boolean;
}
export interface PublicFrame {
@@ -41,6 +43,7 @@ export function createPublicFrame(opts: PublicFrameOptions): PublicFrame {
const root = node("div", "ui-public-frame");
const hero = node("section", "ui-public-frame__hero");
hero.dataset.align = opts.align ?? "center";
if (opts.wide) hero.dataset.wide = "";
const inner = node("div", "ui-public-frame__hero-inner");
inner.append(node("h1", "ui-public-frame__title", opts.title));
if (opts.lead) inner.append(node("p", "ui-public-frame__lead", opts.lead));