i18n(supporters): 전역 언어 전환(KO/EN)을 헤더 하나로 통일, 영어 홈 /en 신설

- 헤더 lang-switch: 현재 페이지의 상대 언어 페이지로 이동(/ ↔ /en, /plan ↔ /en/plan, 그 외는 언어 홈). 구조 기준선 갱신(haewon 승인)
- 영어 페이지에서는 메뉴·CTA·배너·뒤로가기·푸터(지원 고지·부작용 고지·링크·주소)까지 함께 전환. lib/i18n.ts, data/factSheetEn.json
- /en 영어 홈: 히어로, Start Here 카드, 병원 숫자, 오는 길(공항 경로·진료시간), 병원 사실, 원칙. 한국어 글은 번역 없이 링크만 안내
- 플래너의 페이지 내 언어 토글 제거, Step 1은 국적만. sitemap·hreflang 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-10 16:38:41 +09:00
parent 378ca7e7cd
commit c750cfd6d9
10 changed files with 217 additions and 35 deletions

View File

@ -16,6 +16,7 @@
"a", "a",
"a", "a",
"a", "a",
"a.lang-switch",
"a.cta", "a.cta",
"main", "main",
"section.hero-light", "section.hero-light",
@ -257,7 +258,6 @@
"a", "a",
"a", "a",
"a", "a",
"a",
"div", "div",
"p", "p",
"img", "img",
@ -287,6 +287,7 @@
"a", "a",
"a", "a",
"a", "a",
"a.lang-switch",
"a.cta", "a.cta",
"div.backbar-wrap", "div.backbar-wrap",
"div.backbar.wrap-wide", "div.backbar.wrap-wide",
@ -485,7 +486,6 @@
"a", "a",
"a", "a",
"a", "a",
"a",
"div", "div",
"p", "p",
"img", "img",
@ -515,6 +515,7 @@
"a", "a",
"a", "a",
"a", "a",
"a.lang-switch",
"a.cta", "a.cta",
"div.backbar-wrap", "div.backbar-wrap",
"div.backbar.wrap-wide", "div.backbar.wrap-wide",
@ -739,7 +740,6 @@
"a", "a",
"a", "a",
"a", "a",
"a",
"div", "div",
"p", "p",
"img", "img",

View File

@ -19,10 +19,6 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
<div class="eyebrow" style="color:var(--purple-300)">{t.eyebrow}</div> <div class="eyebrow" style="color:var(--purple-300)">{t.eyebrow}</div>
<h1>{t.h_hero}</h1> <h1>{t.h_hero}</h1>
<p class="lede"><strong>{t.title}</strong> {t.lede}</p> <p class="lede"><strong>{t.title}</strong> {t.lede}</p>
<div class="lang-links" role="group" aria-label="Language">
<a href="/plan" data-lang="ko" class={lang === 'ko' ? 'on' : ''}><span class="ico">KO</span>한국어</a>
<a href="/en/plan" data-lang="en" class={lang === 'en' ? 'on' : ''}><span class="ico">EN</span>English</a>
</div>
</div> </div>
<div class="stats"> <div class="stats">
<div class="stat"><b>{nProcs}</b><span class="label">{t.stat_procs}</span><span class="desc">{t.stat_procs_d}</span></div> <div class="stat"><b>{nProcs}</b><span class="label">{t.stat_procs}</span><span class="desc">{t.stat_procs_d}</span></div>
@ -53,7 +49,6 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
const PREFS = ['sightseeing', 'shopping', 'food', 'kpop', 'care']; const PREFS = ['sightseeing', 'shopping', 'food', 'kpop', 'care'];
const root = document.getElementById('planner')!; const root = document.getElementById('planner')!;
const pageLang = (root.dataset.lang === 'en' ? 'en' : 'ko') as Lang; const pageLang = (root.dataset.lang === 'en' ? 'en' : 'ko') as Lang;
const pageUrl = (l: Lang) => (l === 'en' ? '/en/plan' : '/plan');
const toastEl = document.getElementById('toast')!; const toastEl = document.getElementById('toast')!;
const sticky = document.getElementById('sticky')!; const sticky = document.getElementById('sticky')!;
const reservationUrl: string = (factSheet as any).reservationUrl || (factSheet as any).url || '/visit'; const reservationUrl: string = (factSheet as any).reservationUrl || (factSheet as any).url || '/visit';
@ -101,8 +96,8 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
<div class="steps-grid"> <div class="steps-grid">
<div class="card"> <div class="card">
${cardHead(1, t.h_step1, t.step1)} ${cardHead(1, t.h_step1, t.step1)}
<div class="field"><label>${esc(t.lang)}</label><div class="lang-inline"><a href="/plan" data-lang="ko" class="${state.lang === 'ko' ? 'on' : ''}">한국어</a><a href="/en/plan" data-lang="en" class="${state.lang === 'en' ? 'on' : ''}">English</a></div></div>
<div class="field"><label for="nat">${esc(t.nationality)}</label><select id="nat" data-action="nat">${natOpts}</select></div> <div class="field"><label for="nat">${esc(t.nationality)}</label><select id="nat" data-action="nat">${natOpts}</select></div>
<p class="hint">${esc(t.h_step1_hint)}</p>
</div> </div>
<div class="card"> <div class="card">
${cardHead(2, t.h_step2, t.step2)} ${cardHead(2, t.h_step2, t.step2)}
@ -263,9 +258,8 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
// ---------- 이벤트 ---------- // ---------- 이벤트 ----------
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action],[data-lang]'); const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!el) return; if (!el) return;
if (el.dataset.lang) { e.preventDefault(); const target = el.dataset.lang as Lang; if (target !== state.lang) { location.href = `${pageUrl(target)}#${encodeHash({ p: state.proc || undefined, d: state.date || undefined, l: target, n: state.nat || undefined, pref: state.prefs })}`; } return; }
const a = el.dataset.action; const a = el.dataset.action;
if (a === 'proc') { state.proc = el.dataset.id!; render(); if (state.date) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } if (a === 'proc') { state.proc = el.dataset.id!; render(); if (state.date) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
else if (a === 'pref') { const id = el.dataset.id!; state.prefs = state.prefs.includes(id) ? state.prefs.filter((x) => x !== id) : [...state.prefs, id]; render(); } else if (a === 'pref') { const id = el.dataset.id!; state.prefs = state.prefs.includes(id) ? state.prefs.filter((x) => x !== id) : [...state.prefs, id]; render(); }

View File

@ -0,0 +1,20 @@
{
"_comment": "영어 페이지용 병원 사실 번역. 값의 원본은 factSheet.json 이고 여기에는 영어 표기만 둔다. 숫자·전화·URL 은 factSheet 에서 그대로 읽는다. 빈 값은 영어 페이지에서 '확인' 대신 항목을 숨긴다.",
"name": "View Plastic Surgery Clinic",
"shortName": "View Plastic Surgery",
"kind": "plastic surgery clinic",
"areaLabel": "Sinnonhyeon Station, Gangnam, Seoul",
"address": {
"full": "107 Bongeunsa-ro, Gangnam-gu, Seoul (View Plastic Surgery Building)",
"mapQuery": "View Plastic Surgery Seoul Bongeunsa-ro 107"
},
"transit": "Line 9, Sinnonhyeon Station, Exit 3. Within 50 m of the exit. The whole building is the clinic, so you can see it from the exit.",
"airport": "From Incheon Airport: take the AREX airport railroad to Gimpo Airport Station, then Line 9 express to Sinnonhyeon Station with no transfer. About 60 to 70 minutes. A taxi takes 60 to 90 minutes depending on traffic.",
"parking": "The street in front of the main entrance is one-way. Enter from the pharmacy alley and turn right at the crossroads. The car park is on the left.",
"hoursDays": { "월·화·수·목": "Mon to Thu", "금": "Fri", "토": "Sat", "일·공휴일": "Sun and public holidays" },
"hoursClosed": "Closed",
"building": "19-storey clinic building (B4 to 15F). Consultation 4F to 5F, examination B1, surgery 11F to 14F, inpatient wards 8F and 10F, dental clinic 15F.",
"arrival": "Go to the information desk on the 1st floor first. Consultation is on 4F to 5F, VIP consultation on 3F, and pre-operative tests at the B1 examination center.",
"specialties": ["Breast augmentation", "Facial contouring", "Jaw surgery", "Eyelid surgery", "Rhinoplasty", "Liposuction and fat grafting", "Anti-aging and lifting", "Men's plastic surgery", "Dermatology", "Hair transplant"],
"languageNote": "Consultation is available in Korean. Ask the clinic about interpreter support when you book."
}

View File

@ -4,7 +4,7 @@
"eyebrow": "Recovery Planner", "eyebrow": "Recovery Planner",
"title": "수술일을 정하셨다면, 여행 일정은 여기서 짜세요.", "title": "수술일을 정하셨다면, 여행 일정은 여기서 짜세요.",
"lede": "시술과 수술일을 고르면 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 그날 갈 만한 곳을 정리해 드립니다. 계산은 이 화면 안에서만 이루어지고 어디에도 저장되지 않습니다.", "lede": "시술과 수술일을 고르면 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 그날 갈 만한 곳을 정리해 드립니다. 계산은 이 화면 안에서만 이루어지고 어디에도 저장되지 않습니다.",
"step1": "언어와 국적", "step1": "국적",
"step2": "시술과 수술일", "step2": "시술과 수술일",
"step3": "여유 시간에 하고 싶은 것", "step3": "여유 시간에 하고 싶은 것",
"step4": "일정표", "step4": "일정표",
@ -76,7 +76,7 @@
"provisionalNote": "\"병원 확인 전\"으로 표시된 값은 병원 공개 안내에 없어 일반 기준으로 넣은 잠정값입니다. 상담에서 본인 일정으로 확정하세요.", "provisionalNote": "\"병원 확인 전\"으로 표시된 값은 병원 공개 안내에 없어 일반 기준으로 넣은 잠정값입니다. 상담에서 본인 일정으로 확정하세요.",
"noRemoval": "제거 방문 없음", "noRemoval": "제거 방문 없음",
"h_hero": "Plan Your Recovery Stay", "h_hero": "Plan Your Recovery Stay",
"h_step1": "Language & Nationality", "h_step1": "Nationality",
"h_step2": "Procedure & Surgery Date", "h_step2": "Procedure & Surgery Date",
"h_step3": "Your Free Time", "h_step3": "Your Free Time",
"h_step4": "Your Schedule", "h_step4": "Your Schedule",
@ -104,7 +104,8 @@
"empty_title": "시술과 수술일을 고르면 여기에 일정표가 나옵니다", "empty_title": "시술과 수술일을 고르면 여기에 일정표가 나옵니다",
"empty_sub": "수술일은 예상일이어도 됩니다.", "empty_sub": "수술일은 예상일이어도 됩니다.",
"stickyView": "일정표 보기", "stickyView": "일정표 보기",
"stickyStay": "{a} → {b} · {n}박" "stickyStay": "{a} → {b} · {n}박",
"h_step1_hint": "국적을 고르면 시차와 무비자 체류 한도를 함께 봅니다."
}, },
"en": { "en": {
"eyebrow": "Recovery Planner", "eyebrow": "Recovery Planner",
@ -182,7 +183,7 @@
"provisionalNote": "Values marked \"Provisional\" are not in the clinic's published guidance and use general estimates. Confirm your own dates at consultation.", "provisionalNote": "Values marked \"Provisional\" are not in the clinic's published guidance and use general estimates. Confirm your own dates at consultation.",
"noRemoval": "No removal visit", "noRemoval": "No removal visit",
"h_hero": "Plan Your Recovery Stay", "h_hero": "Plan Your Recovery Stay",
"h_step1": "Language & Nationality", "h_step1": "Nationality",
"h_step2": "Procedure & Surgery Date", "h_step2": "Procedure & Surgery Date",
"h_step3": "Your Free Time", "h_step3": "Your Free Time",
"h_step4": "Your Schedule", "h_step4": "Your Schedule",
@ -210,6 +211,7 @@
"empty_title": "Pick a procedure and a surgery date to see your schedule here", "empty_title": "Pick a procedure and a surgery date to see your schedule here",
"empty_sub": "An estimated date is fine.", "empty_sub": "An estimated date is fine.",
"stickyView": "See schedule", "stickyView": "See schedule",
"stickyStay": "{a} → {b} · {n} nights" "stickyStay": "{a} → {b} · {n} nights",
"h_step1_hint": "Your nationality sets the jet-lag buffer and the visa-free stay check."
} }
} }

View File

@ -1,6 +1,8 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib'; import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib';
import { UI, switchHref } from '../lib/i18n';
import EN from '../data/factSheetEn.json';
const ed = editor(); const ed = editor();
const f = fact as Record<string, any>; const f = fact as Record<string, any>;
@ -18,6 +20,12 @@ const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.
const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
const u = UI[lang];
const other = lang === 'ko' ? 'en' : 'ko';
const switchTo = switchHref(Astro.url.pathname, other);
const clinicName = lang === 'en' ? EN.shortName : v(f.shortName, '병원');
const homeHref = lang === 'en' ? '/en' : '/';
const isHome = ['/', '', '/index.html', '/index', '/en', '/en/index.html', '/en/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/');
const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null;
--- ---
<!doctype html> <!doctype html>
@ -44,32 +52,44 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
{ld && <script type="application/ld+json" set:html={ld} />} {ld && <script type="application/ld+json" set:html={ld} />}
</head> </head>
<body> <body>
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>} {!INDEXABLE && <div class="sample-banner">{u.sample}</div>}
<header class="site-header"> <header class="site-header">
<div class="wrap-wide bar"> <div class="wrap-wide bar">
<a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">서포터즈 <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a> <a class="brand" href={homeHref}>{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">{u.brand} <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
<div class="right"> <div class="right">
<nav class="nav"> <nav class="nav">
<a href="/posts">상담 전 질문</a> {lang === 'ko' ? (
<a href="/videos">의료진 영상</a> <>
<a href="/newsroom">뉴스룸</a> <a href="/posts">{u.nav_posts}</a>
<a href="/visit">방문 안내</a> <a href="/videos">{u.nav_videos}</a>
<a href="/plan">회복 일정</a> <a href="/newsroom">{u.nav_news}</a>
<a href="/about">매체 소개</a> <a href="/visit">{u.nav_visit}</a>
<a href="/plan">{u.nav_plan}</a>
<a href="/about">{u.nav_about}</a>
</>
) : (
<>
<a href="/en">{u.nav_home}</a>
<a href="/en/plan">{u.nav_plan}</a>
<a href="/en#visit">{u.nav_visit}</a>
<a href="/en#clinic">{u.nav_clinic}</a>
</>
)}
</nav> </nav>
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a> <a class="lang-switch" href={switchTo} aria-label={u.switchAria} hreflang={other} lang={other}>{u.switchLabel}</a>
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">{u.cta}</a>
</div> </div>
</div> </div>
</header> </header>
{!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && ( {!isHome && (
<div class="backbar-wrap"><div class="wrap-wide backbar"> <div class="backbar-wrap"><div class="wrap-wide backbar">
<a href="/" class="backbtn" id="backbtn" aria-label="이전 페이지로 돌아가기"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>이전 페이지</a> <a href={homeHref} class="backbtn" id="backbtn" aria-label={u.backAria}><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>{u.back}</a>
</div></div> </div></div>
)} )}
<main> <main>
<slot /> <slot />
</main> </main>
<button type="button" class="totop" id="totop" aria-label="맨 위로"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button> <button type="button" class="totop" id="totop" aria-label={u.totop}><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button>
<script> <script>
const back = document.getElementById('backbtn'); const back = document.getElementById('backbtn');
if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } }); if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } });
@ -82,15 +102,19 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<div class="wrap-wide cols"> <div class="wrap-wide cols">
<div> <div>
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p> <p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p> <p>{u.foot_sponsor.replace(/\{clinic\}/g, clinicName).replace('{editor}', ed.name)}</p>
<p>{fact.sideEffectNotice}</p> <p>{lang === 'en' ? u.foot_side : fact.sideEffectNotice}</p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/plan">회복 일정</a> · <a href="/en/plan">Recovery Planner</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p> {lang === 'ko' ? (
<p><a href="/clinic">{u.foot_clinic}</a> · <a href="/visit">{u.foot_visit}</a> · <a href="/plan">{u.foot_plan}</a> · <a href="/about">{u.foot_about}</a> · <a href="/corrections">{u.foot_corrections}</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>{u.foot_request}</a> · <a href="/editorial">{u.foot_editorial}</a></p>
) : (
<p><a href="/en#clinic">{u.foot_clinic}</a> · <a href="/en#visit">{u.foot_visit}</a> · <a href="/en/plan">{u.foot_plan}</a> · <a href="/about">{u.foot_about}</a> · <a href="/corrections">{u.foot_corrections}</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>{u.foot_request}</a></p>
)}
</div> </div>
<div> <div>
{has(S.logoFoot?.src) && <p><img src={S.logoFoot.src} alt={S.logoFoot.alt} width={S.logoFoot.width} height={S.logoFoot.height} style="width:138px;height:auto;margin-bottom:0.5rem" /></p>} {has(S.logoFoot?.src) && <p><img src={S.logoFoot.src} alt={S.logoFoot.alt} width={S.logoFoot.width} height={S.logoFoot.height} style="width:138px;height:auto;margin-bottom:0.5rem" /></p>}
<p><strong>{v(f.name)}</strong></p> <p><strong>{lang === 'en' ? EN.name : v(f.name)}</strong></p>
<p>{v(f.address?.full)}<br />전화 {v(f.phone)} · 팩스 {v(f.fax)}<br />대표 {v(f.representative)} · 사업자등록번호 {v(f.businessNo)}</p> <p>{lang === 'en' ? EN.address.full : v(f.address?.full)}<br />{u.foot_phone} {v(f.phone)} · {u.foot_fax} {v(f.fax)}<br />{u.foot_rep} {v(f.representative)} · {u.foot_biz} {v(f.businessNo)}</p>
<p>{has(f.url) && <a href={f.url} rel="noopener">공식 홈페이지</a>}{has(surface('youtube').url) && <> · <a href={surface('youtube').url} rel="noopener">유튜브</a></>}{has(surface('gangnamunni').url) && <> · <a href={surface('gangnamunni').url} rel="noopener">강남언니</a></>}</p> <p>{lang === 'en' && has(f.urlEn) ? <a href={f.urlEn} rel="noopener">{u.foot_site}</a> : has(f.url) && <a href={f.url} rel="noopener">{u.foot_site}</a>}{has(surface('youtube').url) && <> · <a href={surface('youtube').url} rel="noopener">{u.foot_youtube}</a></>}{has(surface('gangnamunni').url) && <> · <a href={surface('gangnamunni').url} rel="noopener">{u.foot_gu}</a></>}</p>
</div> </div>
</div> </div>
<div class="wrap-wide powered">AI Discovery · Built with INFINITH</div> <div class="wrap-wide powered">AI Discovery · Built with INFINITH</div>

View File

@ -0,0 +1,46 @@
// 사이트 공통(헤더·푸터·배너) 문자열과 언어 전환 규칙. 페이지 본문은 각 페이지가 맡고, 이 모듈은 Base.astro 가 쓴다.
// 언어 전환은 전역 하나(헤더)로만 한다. 페이지마다 따로 토글을 두지 않는다 (haewon 결정, 2026-09-10).
export type Lang = 'ko' | 'en';
/** 한국어 경로 ↔ 영어 경로. 영어판이 없는 한국어 페이지는 영어 홈으로, 반대는 한국어 홈으로 보낸다. */
const PAIRS: [string, string][] = [
['/', '/en'],
['/plan', '/en/plan'],
];
export function switchHref(pathname: string, to: Lang): string {
const p = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '').replace(/\/$/, '') || '/';
for (const [ko, en] of PAIRS) {
if (to === 'en' && p === ko) return en;
if (to === 'ko' && p === en) return ko;
}
return to === 'en' ? '/en' : '/';
}
export const isEnPath = (pathname: string): boolean => /^\/en(\/|\.html$|$)/.test(pathname);
export const UI: Record<Lang, Record<string, string>> = {
ko: {
sample: '샘플 사이트입니다. 정식 공개 전입니다.',
brand: '서포터즈',
nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개',
cta: '상담 예약',
back: '이전 페이지', backAria: '이전 페이지로 돌아가기', totop: '맨 위로',
switchLabel: 'EN', switchAria: 'Switch to English',
foot_sponsor: '이 매체는 {clinic}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {clinic} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {editor}.',
foot_clinic: '병원 정보', foot_visit: '방문 안내', foot_plan: '회복 일정', foot_about: '이 사이트에 대해', foot_corrections: '정정 기록', foot_request: '정정 요청', foot_editorial: '편집 기준 (운영자용)',
foot_phone: '전화', foot_fax: '팩스', foot_rep: '대표', foot_biz: '사업자등록번호',
foot_site: '공식 홈페이지', foot_youtube: '유튜브', foot_gu: '강남언니',
},
en: {
sample: 'Sample site. Not yet public.',
brand: 'Supporters',
nav_home: 'Home', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기',
cta: 'Book a consultation',
back: 'Back', backAria: 'Go back to the previous page', totop: 'Back to top',
switchLabel: 'KO', switchAria: '한국어로 보기',
foot_sponsor: 'This site is run by supporters with support from {clinic}. Medical content is reviewed by the clinic\'s attending surgeon before it is marked as reviewed. Editor in charge: {editor}.',
foot_side: 'After surgery, side effects such as inflammation, bleeding or nerve damage may occur depending on the individual.',
foot_clinic: 'Clinic facts', foot_visit: 'Getting here', foot_plan: 'Recovery planner', foot_about: 'About this site (Korean)', foot_corrections: 'Corrections (Korean)', foot_request: 'Request a correction', foot_editorial: '',
foot_phone: 'Tel', foot_fax: 'Fax', foot_rep: 'Representative', foot_biz: 'Business no.',
foot_site: 'Official website (English)', foot_youtube: 'YouTube', foot_gu: 'Gangnam Unni',
},
};

View File

@ -0,0 +1,92 @@
---
// 영어 홈. 헤더의 언어 전환(KO/EN)이 도착하는 곳. 구성은 한국어 홈과 같은 섹션 리듬(라이트 히어로 → 틴트 카드 → 다크 통계 → 라이트 → 다크)이고,
// 내용은 영어로 읽을 수 있는 것만 싣는다: 회복 일정 플래너, 오는 길, 진료시간, 병원 사실, 원칙. 한국어 글은 번역 없이 링크만 안내한다.
import Base from '../../layouts/Base.astro';
import { fact, site as S, clinicSchema, surface, has, v } from '../../lib';
import EN from '../../data/factSheetEn.json';
const f = fact as Record<string, any>;
const site = Astro.site!.toString().replace(/\/$/, '');
const yt = surface('youtube'); const gu = surface('gangnamunni'); const gg = surface('google');
const hoursDays = EN.hoursDays as Record<string, string>;
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en#page`, name: `${EN.shortName} Supporters (English)`, url: `${site}/en`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` } },
clinicSchema(site),
];
---
<Base lang="en" title={`${EN.shortName}, Seoul · Recovery planner, getting here, clinic facts`} description={`${EN.shortName} is a ${EN.kind} at ${EN.areaLabel}, founded in ${v(f.founded)}. Plan your recovery stay around your surgery date, find the way from the airport, and check opening hours and clinic facts in English.`} jsonLd={ld} alternates={[{ lang: 'ko', href: '/' }, { lang: 'en', href: '/en' }]}>
<section class="hero-light">
<div class="blob a"></div><div class="blob b"></div>
<div class="wrap-wide inner">
<div>
<div class="eyebrow">{S.siteNameEn || 'VIEW SUPPORTERS'} · English</div>
<h1>{EN.shortName},<br /><span class="accent">plan your stay before you fly.</span></h1>
<p class="lede">{EN.shortName} is a {EN.kind} at {EN.areaLabel}{has(f.founded) && <>, founded in {f.founded}</>}. This site is run by supporters who answer the questions people ask before visiting. In English you can plan your recovery stay, find the way here and check clinic facts. Articles are in Korean.</p>
<div class="chips">
<span class="chip"><span class="dot"></span>Founded {v(f.founded)} · Sinnonhyeon Station Exit 3</span>
<span class="chip"><span class="dot"></span>Based on the clinic's published guidance</span>
</div>
<div style="display:flex;gap:0.8rem;flex-wrap:wrap;margin-top:1.6rem">
<a class="btn primary" href="/en/plan">Recovery Planner</a>
<a class="btn secondary" href="#visit">Getting here</a>
</div>
</div>
{has(S.heroImage?.src) && <div class="hero-img collage"><img src={S.heroImage.src} alt="View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor" width={S.heroImage.width} height={S.heroImage.height} /></div>}
</div>
</section>
<section class="section tint" style="padding:3.2rem 0"><div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">Start Here</div><h2>Where Are You Now?</h2><p class="sub">Pick the step that matches where you are.</p></div>
<div class="grid">
<a class="card entry" href="/en/plan"><div class="cat">Surgery date set</div><h3>Plan arrival, departure and recovery days</h3><p>Choose your procedure and date. See when to arrive, the earliest you can fly home, which days you can go out, and hotels near the clinic.</p><span class="more">Open the Recovery Planner</span></a>
<a class="card entry" href="#visit"><div class="cat">Preparing to visit</div><h3>Airport to clinic, hours and parking</h3><p>Line 9 from Gimpo Airport with no transfer, opening hours by day, and where to go when you arrive.</p><span class="more">Getting here</span></a>
<a class="card entry" href="#clinic"><div class="cat">Checking the clinic</div><h3>Facts, building and reviews</h3><p>Founded {v(f.founded)}, 19-storey clinic building, specialties, and where reviews can be read.</p><span class="more">Clinic facts</span></a>
</div>
</div></section>
<section class="section dark"><div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">At A Glance</div><h2>Clinic In Numbers</h2><p class="sub">Figures from the clinic's public profiles. Dates checked are shown with each number.</p></div>
<div class="stats">
<div class="stat"><b>{v(f.founded)}</b><span class="label">Founded</span><span class="desc">{EN.areaLabel}. {EN.building}</span></div>
<div class="stat"><b>{v(gu.doctors)}</b><span class="label">Doctors listed</span><span class="desc">Gangnam Unni clinic profile, checked {v(gu.checked)}</span></div>
<div class="stat"><b>{v(yt.videos)}</b><span class="label">Doctor videos</span><span class="desc">Official YouTube channel (Korean), checked {v(yt.checked)}</span></div>
<div class="stat"><b>{v(gu.reviews)}</b><span class="label">Reviews on Gangnam Unni</span><span class="desc">We link to where reviews can be read and do not reproduce patient stories.</span></div>
</div>
</div></section>
<section class="section light" id="visit"><div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">Visit</div><h2>Getting Here</h2><p class="sub">Address, transit, airport route, opening hours and what to do when you arrive.</p></div>
<div class="grid">
<div class="card"><div class="cat">Address</div><h3>{EN.address.full}</h3><p>{EN.transit}</p><p><a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(EN.address.mapQuery)}`} rel="noopener" target="_blank">Open in Google Maps</a></p></div>
<div class="card"><div class="cat">From the airport</div><h3>Incheon to Sinnonhyeon</h3><p>{EN.airport}</p></div>
<div class="card"><div class="cat">On arrival</div><h3>Where to go in the building</h3><p>{EN.arrival}</p><p>{EN.parking}</p></div>
</div>
<h3 style="margin-top:2rem">Opening hours</h3>
<div class="table-wrap"><table><thead><tr><th>Day</th><th>Hours</th></tr></thead><tbody>
{(f.hours?.rows ?? []).map((r: any) => <tr><td>{hoursDays[r.days] ?? r.days}</td><td>{r.open ? `${r.open} to ${r.close}` : EN.hoursClosed}</td></tr>)}
</tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">Source: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">clinic profile</a> : 'clinic'}. Hours can change, so call or message before you travel. {EN.languageNote}</p>
<p style="margin-top:1.2rem"><a class="btn primary" href={f.reservationUrl || f.url} rel="noopener">Book a consultation</a> {has(f.urlEn) && <a class="btn secondary" href={f.urlEn} rel="noopener">Official English website</a>}</p>
</div></section>
<section class="section tint" id="clinic"><div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">Fact Sheet</div><h2>Clinic Facts</h2><p class="sub">What the clinic states publicly, with the date we checked.</p></div>
<div class="grid">
<div class="card"><div class="cat">Specialties</div><h3>What the clinic does</h3><ul style="margin:0;padding-left:1.1rem;font-size:0.93rem;color:var(--slate-600)">{EN.specialties.map((s) => <li>{s}</li>)}</ul></div>
<div class="card"><div class="cat">Contact</div><h3>{EN.name}</h3><p>Tel {v(f.phone)}{has(f.kakao) && <> · <a href={f.kakao} rel="noopener">KakaoTalk channel</a></>}</p><p>{has(f.url) && <a href={f.url} rel="noopener">Korean website</a>}{has(f.urlEn) && <> · <a href={f.urlEn} rel="noopener">English website</a></>}</p></div>
<div class="card"><div class="cat">Reviews</div><h3>Where to read them</h3><p>{has(gu.url) && <a href={gu.url} rel="noopener">Gangnam Unni</a>}{has(gu.rating) && <> rating {gu.rating}, {v(gu.reviews)} reviews</>}{has(gg.url) && <> · <a href={gg.url} rel="noopener">Google Maps</a>{has(gg.rating) && <> rating {gg.rating}</>}</>}. We do not quote individual patient stories.</p></div>
</div>
</div></section>
<section class="section dark"><div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Principles</div><h2>What We Do, What We Don't</h2><p class="sub">Three rules so you can judge for yourself.</p></div>
<div class="grid">
<div class="card"><h3>How we answer</h3><p>One question, one article, the answer first. Medical content is reviewed by the clinic's attending surgeon and the review date is shown. Articles are in Korean.</p></div>
<div class="card"><h3>What we leave out</h3><p>Before-and-after photos, other patients' stories, comparisons with other clinics, and any promise of results. For reviews we only say where they can be read.</p></div>
<div class="card"><h3>What we disclose</h3><p>Who wrote each piece, whether a surgeon reviewed it, what sources were used, and that this site is supported by {EN.shortName}.</p></div>
</div>
</div></section>
</Base>
<style>
.card.entry { text-decoration: none; color: inherit; }
.card .more { font-size: 0.85rem; font-weight: 600; color: var(--accent); }
</style>

View File

@ -17,7 +17,7 @@ const ld = [
clinicSchema(site), clinicSchema(site),
]; ];
--- ---
<Base title={`${clinic}, ${(S.heroTitle || '궁금한 것부터 답합니다').replace(/\.$/, '')}`} description={`${clinic}${has(f.areaLabel) ? `(${f.areaLabel})` : ''}를 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원 공개 자료를 근거로 답합니다. 글마다 작성자와 원장 감수 여부를 밝힙니다.`} jsonLd={ld}> <Base title={`${clinic}, ${(S.heroTitle || '궁금한 것부터 답합니다').replace(/\.$/, '')}`} description={`${clinic}${has(f.areaLabel) ? `(${f.areaLabel})` : ''}를 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원 공개 자료를 근거로 답합니다. 글마다 작성자와 원장 감수 여부를 밝힙니다.`} jsonLd={ld} alternates={[{ lang: 'ko', href: '/' }, { lang: 'en', href: '/en' }]}>
<section class="hero-light"> <section class="hero-light">
<div class="blob a"></div><div class="blob b"></div> <div class="blob a"></div><div class="blob b"></div>
<div class="wrap-wide inner"> <div class="wrap-wide inner">

View File

@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
{ loc: `${base}/posts`, lastmod: today }, { loc: `${base}/posts`, lastmod: today },
{ loc: `${base}/clinic`, lastmod: today }, { loc: `${base}/clinic`, lastmod: today },
{ loc: `${base}/visit`, lastmod: today }, { loc: `${base}/visit`, lastmod: today },
{ loc: `${base}/en`, lastmod: today },
{ loc: `${base}/plan`, lastmod: today }, { loc: `${base}/plan`, lastmod: today },
{ loc: `${base}/en/plan`, lastmod: today }, { loc: `${base}/en/plan`, lastmod: today },
{ loc: `${base}/newsroom`, lastmod: today }, { loc: `${base}/newsroom`, lastmod: today },

View File

@ -199,6 +199,8 @@ td { color: var(--slate-700); }
/* 헤더: INFINITH 헤더 규격 (흰 배경, 우측 그라디언트 pill CTA) */ /* 헤더: INFINITH 헤더 규격 (흰 배경, 우측 그라디언트 pill CTA) */
.site-header .right { display: flex; align-items: center; gap: 1rem; } .site-header .right { display: flex; align-items: center; gap: 1rem; }
.site-header .lang-switch { display: inline-grid; place-items: center; min-width: 2.4rem; height: 2rem; padding: 0 0.6rem; border-radius: 999px; border: 1px solid var(--slate-200); color: var(--slate-600); font-family: Inter, Pretendard, sans-serif; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-decoration: none; }
.site-header .lang-switch:hover { color: var(--primary-900); border-color: var(--primary-900); text-decoration: none; }
.site-header .cta { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-radius: 999px; padding: 0.5rem 1.05rem; font-size: 0.86rem; font-weight: 600; } .site-header .cta { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-radius: 999px; padding: 0.5rem 1.05rem; font-size: 0.86rem; font-weight: 600; }
.site-header .cta:hover { text-decoration: none; box-shadow: var(--shadow); } .site-header .cta:hover { text-decoration: none; box-shadow: var(--shadow); }
.nav a, .site-header .cta, .brand-text { white-space: nowrap; } .nav a, .site-header .cta, .brand-text { white-space: nowrap; }
@ -270,6 +272,7 @@ td { color: var(--slate-700); }
.site-header .bar { height: auto; flex-wrap: wrap; padding-top: 0.6rem; padding-bottom: 0; row-gap: 0.1rem; } .site-header .bar { height: auto; flex-wrap: wrap; padding-top: 0.6rem; padding-bottom: 0; row-gap: 0.1rem; }
.site-header .brand { flex: 1; } .site-header .brand { flex: 1; }
.site-header .right { display: contents; } .site-header .right { display: contents; }
.site-header .lang-switch { order: 2; margin-right: 0.5rem; }
.site-header .cta { order: 2; } .site-header .cta { order: 2; }
.site-header .nav { order: 3; display: flex; gap: 1rem; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 0.35rem 0 0.7rem; width: 100%; } .site-header .nav { order: 3; display: flex; gap: 1rem; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 0.35rem 0 0.7rem; width: 100%; }
.site-header .nav::-webkit-scrollbar { display: none; } .site-header .nav::-webkit-scrollbar { display: none; }