From 4925a1ad47a504431fec73659501adf2b4f83231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Park=20hyeon=20gyu=28=EB=B0=94=EA=B2=AC=EA=B7=9C=29?= Date: Thu, 15 Oct 2026 18:34:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AA=A9=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=82=A8=EA=B8=B0=EB=8A=94=20=EC=9D=98=EA=B2=AC=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=8B=A0=EC=84=A4=20(/feedback/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 의견을 메일보다 가볍게 받을 곳을 만든다. 글은 댓글처럼 닉네임 (비우면 익명)과 내용만 받고 제목은 두지 않는다. 남긴 글과 운영팀 답변은 같은 페이지에 공개된다. 스타일은 지원 페이지(landing.css)를 그대로 따랐다. 서버는 두지 않는다. 운영 계정 드라이브의 스프레드시트에 바인딩한 Apps Script 웹 앱이 읽기·쓰기를 맡고(scripts/feedback/Code.gs), 운영자는 시트의 답변·공개 칸으로 답변과 숨김을 관리한다. scripts/는 배포 산출물에 들어가지 않으므로 서버 코드는 공개 사이트에 올라가지 않는다. 보안상 판단: - @OnlyCurrentDoc으로 권한을 이 시트 하나로 줄였다. 웹 앱 액세스가 「모든 사용자」라 호출은 누구나 할 수 있지만, 할 수 있는 일은 공개 글 읽기와 글쓰기뿐이다. - 글은 textContent로만 그려 XSS를 막고, =·+·-·@로 시작하는 입력은 시트 수식으로 실행되지 않게 저장한다. - 작성 제한은 브라우저별로 건다(30초 간격, 시간당 5개). Apps Script는 IP를 알 수 없어 페이지가 저장한 무작위 ID로 구분한다. 사이트 전체 제한은 한 사람의 도배가 모두의 글쓰기를 막으므로 두지 않았다. ID를 바꾸는 봇은 막지 못한다 — 필요하면 Turnstile을 붙인다. - 「널스메이트」「운영자」 등이 들어간 닉네임은 운영팀 사칭이 되므로 페이지와 서버 양쪽에서 거절한다. - 전송은 keepalive로 보내 보내는 중에 창을 닫아도 요청이 끝까지 간다. 랜딩 푸터·FAQ, 지원 페이지 상단·푸터에서 게시판으로 연결하고, 사이트맵과 build-pages.py 배포 대상에 feedback을 추가했다. Co-Authored-By: Claude Opus 5.5 --- feedback/index.html | 296 +++++++++++++++++++++++++++++++++++++ index.html | 4 +- scripts/build-pages.py | 2 +- scripts/feedback/Code.gs | 146 ++++++++++++++++++ scripts/feedback/README.md | 22 +++ sitemap.xml | 1 + support/index.html | 6 +- 7 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 feedback/index.html create mode 100644 scripts/feedback/Code.gs create mode 100644 scripts/feedback/README.md diff --git a/feedback/index.html b/feedback/index.html new file mode 100644 index 0000000..ba7804a --- /dev/null +++ b/feedback/index.html @@ -0,0 +1,296 @@ + + + + + + 의견 게시판 · 널스메이트 + + + + + + + + + +
+ + +
+ + + + diff --git a/index.html b/index.html index 6f4676d..13b85cb 100644 --- a/index.html +++ b/index.html @@ -58,9 +58,9 @@

간호사분들의
더 나은 하루를 위해

03 / 워치 연동

주머니 속에서
손목 위로.

휴대폰을 꺼내기 어려운 순간에도,
손목에서 처치 타이머를 확인하세요.

휴대폰 + 스마트워치
워치 처치 타이머 목록 화면 예시
워치 타이머 상세 화면 예시
-

궁금한 점이 있나요?

자주 묻는 질문

도움이 더 필요하다면
어떤 기기에서 사용할 수 있나요?

iOS는 App Store에서, Android는 Google Play에서 다운로드할 수 있어요. iOS 앱은 iOS 17 이상, iOS의 처치 타이머는 iOS 26.1 이상에서 이용할 수 있습니다.

알약 식별 결과만으로 투약해도 되나요?

알약 식별 결과는 참고용 보조 정보예요. 투약 전 반드시 처방 내용과 약품 라벨을 확인하고, 최종 판단은 의료진의 확인을 따라 주세요.

로그인이 필요한가요?

네, 널스메이트는 로그인 후 이용할 수 있어요. Google, Apple, 카카오 계정으로 시작할 수 있습니다.

문의나 의견은 어디로 보내나요?

지원 페이지를 방문하거나 2park1bae@gmail.com으로 보내주세요. 현장의 의견을 기다리고 있어요.

+

궁금한 점이 있나요?

자주 묻는 질문

도움이 더 필요하다면
어떤 기기에서 사용할 수 있나요?

iOS는 App Store에서, Android는 Google Play에서 다운로드할 수 있어요. iOS 앱은 iOS 17 이상, iOS의 처치 타이머는 iOS 26.1 이상에서 이용할 수 있습니다.

알약 식별 결과만으로 투약해도 되나요?

알약 식별 결과는 참고용 보조 정보예요. 투약 전 반드시 처방 내용과 약품 라벨을 확인하고, 최종 판단은 의료진의 확인을 따라 주세요.

로그인이 필요한가요?

네, 널스메이트는 로그인 후 이용할 수 있어요. Google, Apple, 카카오 계정으로 시작할 수 있습니다.

문의나 의견은 어디로 보내나요?

의견은 의견 게시판에 남겨 주세요. 문의는 지원 페이지를 방문하거나 2park1bae@gmail.com으로 보내주세요. 현장의 의견을 기다리고 있어요.

널스메이트 앱 아이콘

오늘도 애쓰는 당신에게

다음 근무부터,
널스메이트와 함께해요.

널스메이트 다운로드 페이지로 연결되는 QR 코드카메라로 QR을 스캔해 보세요

휴대폰에 맞는 다운로드 안내로 연결돼요.

다운로드 페이지 열기
- + diff --git a/scripts/build-pages.py b/scripts/build-pages.py index 2c16294..d489813 100644 --- a/scripts/build-pages.py +++ b/scripts/build-pages.py @@ -10,7 +10,7 @@ "index.html", "404.html", "styles.css", "landing.css", "CNAME", ".nojekyll", "robots.txt", "sitemap.xml", ) -SITE_DIRECTORIES = ("assets", "terms", "privacy", "support", "download") +SITE_DIRECTORIES = ("assets", "terms", "privacy", "support", "download", "feedback") def copy_site(source, target): diff --git a/scripts/feedback/Code.gs b/scripts/feedback/Code.gs new file mode 100644 index 0000000..de6e354 --- /dev/null +++ b/scripts/feedback/Code.gs @@ -0,0 +1,146 @@ +/** + * @OnlyCurrentDoc + * 위 표시로 이 스크립트는 붙여 넣은 스프레드시트 하나에만 접근 권한을 요청한다 (드라이브의 다른 파일은 못 건드림). + */ + +/** + * 널스메이트 의견 게시판 서버 (Google Apps Script 웹 앱) + * + * 붙여 넣을 스프레드시트의 "글" 탭에 의견을 저장하고, 공개된 글과 운영자 답변을 돌려준다. + * 운영자는 시트에서 직접 관리한다. + * - 답변: "답변" 칸에 적으면 페이지에 "널스메이트 팀" 답변으로 보인다. 답변시각은 자동 기록. + * - 숨김: "공개" 칸을 N으로 바꾸면 페이지에서 사라진다. + */ + +const SHEET_NAME = '글'; +const HEADERS = ['ID', '작성시각', '닉네임', '내용', '답변', '답변시각', '공개']; +const COL = { id: 1, createdAt: 2, nickname: 3, content: 4, reply: 5, repliedAt: 6, visible: 7 }; + +// true면 새 글이 "공개" N으로 저장되어, 운영자가 Y로 바꿔야 보인다. +const REQUIRE_APPROVAL = false; +const MAX_CONTENT = 1000; +const MAX_NICKNAME = 20; +const MAX_LIST = 200; +// 브라우저(작성자)별 제한. Apps Script는 IP를 알 수 없어 페이지가 브라우저에 저장한 ID로 구분한다. +const COOLDOWN_SECONDS = 30; +const POSTS_PER_HOUR = 5; +// 운영팀 사칭 방지: 공백·대소문자 무시하고 이 단어가 들어간 닉네임은 받지 않는다. +const RESERVED_NICKNAMES = ['널스메이트', 'nursemate', '운영자', '운영팀', '관리자', 'admin', '투박한배', 'twoparkonebae']; + +function doGet() { + const sheet = getSheet_(); + const rows = sheet.getLastRow() > 1 + ? sheet.getRange(2, 1, sheet.getLastRow() - 1, HEADERS.length).getValues() + : []; + const posts = rows + .filter(function (r) { return String(r[COL.visible - 1]).trim().toUpperCase() !== 'N' && r[COL.content - 1] !== ''; }) + .map(function (r) { + return { + id: String(r[COL.id - 1]), + createdAt: toIso_(r[COL.createdAt - 1]), + nickname: String(r[COL.nickname - 1] || ''), + content: String(r[COL.content - 1]), + reply: String(r[COL.reply - 1] || ''), + repliedAt: toIso_(r[COL.repliedAt - 1]), + }; + }) + .reverse() + .slice(0, MAX_LIST); + return json_({ ok: true, posts: posts }); +} + +function doPost(e) { + let body; + try { + body = JSON.parse(e.postData.contents); + } catch (err) { + return json_({ ok: false, error: 'invalid' }); + } + // 사람에게 보이지 않는 칸이 채워져 있으면 봇으로 보고 성공처럼 응답만 한다. + if (body.website) return json_({ ok: true }); + + const content = String(body.content || '').trim(); + const nickname = String(body.nickname || '').trim().slice(0, MAX_NICKNAME); + const client = String(body.client || ''); + if (!/^[A-Za-z0-9-]{16,64}$/.test(client)) return json_({ ok: false, error: 'invalid' }); + if (!content) return json_({ ok: false, error: 'empty' }); + if (isReservedNickname_(nickname)) return json_({ ok: false, error: 'reserved_nickname' }); + if (content.length > MAX_CONTENT) return json_({ ok: false, error: 'too_long' }); + + const lock = LockService.getScriptLock(); + if (!lock.tryLock(10000)) return json_({ ok: false, error: 'busy' }); + try { + const limited = takeRateSlot_(client); + if (limited) return json_({ ok: false, error: limited }); + getSheet_().appendRow([ + Utilities.getUuid().slice(0, 8), + new Date(), + safeCell_(nickname), + safeCell_(content), + '', + '', + REQUIRE_APPROVAL ? 'N' : 'Y', + ]); + } finally { + lock.releaseLock(); + } + return json_({ ok: true, pending: REQUIRE_APPROVAL }); +} + +// 운영자가 "답변" 칸을 고치면 답변시각을 채운다 (설치 없이 동작하는 단순 트리거). +function onEdit(e) { + const range = e.range; + if (range.getSheet().getName() !== SHEET_NAME || range.getRow() < 2) return; + if (range.getColumn() > COL.reply || range.getLastColumn() < COL.reply) return; + for (let row = range.getRow(); row <= range.getLastRow(); row++) { + const sheet = range.getSheet(); + const reply = sheet.getRange(row, COL.reply).getValue(); + sheet.getRange(row, COL.repliedAt).setValue(reply === '' ? '' : new Date()); + } +} + +function getSheet_() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + let sheet = ss.getSheetByName(SHEET_NAME); + if (!sheet) { + sheet = ss.insertSheet(SHEET_NAME); + sheet.appendRow(HEADERS); + sheet.setFrozenRows(1); + sheet.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold'); + sheet.setColumnWidth(COL.content, 420); + sheet.setColumnWidth(COL.reply, 320); + } + return sheet; +} + +// 제한에 걸리면 오류 코드를, 통과하면 null을 돌려준다. +function takeRateSlot_(client) { + const cache = CacheService.getScriptCache(); + const cooldownKey = 'cooldown:' + client; + const hourKey = 'hour:' + client + ':' + Math.floor(Date.now() / 3600000); + if (cache.get(cooldownKey)) return 'too_fast'; + const count = Number(cache.get(hourKey) || 0); + if (count >= POSTS_PER_HOUR) return 'rate_limited'; + cache.put(cooldownKey, '1', COOLDOWN_SECONDS); + cache.put(hourKey, String(count + 1), 3700); + return null; +} + +function isReservedNickname_(nickname) { + const normalized = nickname.replace(/\s+/g, '').toLowerCase(); + return RESERVED_NICKNAMES.some(function (word) { return normalized.indexOf(word) !== -1; }); +} + +// =, +, -, @ 로 시작하는 입력이 시트에서 수식으로 실행되지 않게 막는다. +function safeCell_(value) { + return /^[=+\-@]/.test(value) ? "'" + value : value; +} + +function toIso_(value) { + return value instanceof Date ? value.toISOString() : ''; +} + +function json_(data) { + return ContentService.createTextOutput(JSON.stringify(data)) + .setMimeType(ContentService.MimeType.JSON); +} diff --git a/scripts/feedback/README.md b/scripts/feedback/README.md new file mode 100644 index 0000000..68278c2 --- /dev/null +++ b/scripts/feedback/README.md @@ -0,0 +1,22 @@ +# 의견 게시판 서버 설정 + +`/feedback/` 페이지는 구글 스프레드시트 + Apps Script 웹 앱에 글을 저장한다. 별도 서버가 없다. +`scripts/`는 배포 산출물에 포함되지 않으므로 `Code.gs`는 공개 사이트에 올라가지 않는다. + +## 처음 한 번 + +1. 운영자 구글 계정의 드라이브에서 새 스프레드시트를 만든다 (예: `널스메이트 의견 게시판`). +2. **확장 프로그램 → Apps Script**를 열고 `Code.gs` 내용을 통째로 붙여 넣은 뒤 저장한다. +3. **배포 → 새 배포** → 유형 **웹 앱**, 실행 **나**, 액세스 **모든 사용자** → 배포. 처음엔 권한 승인 창이 뜨는데 허용한다. +4. 나온 `https://script.google.com/macros/s/.../exec` 주소를 `feedback/index.html`의 `const API = ""`에 넣는다. +5. 페이지에서 글을 하나 써 보면 시트에 `글` 탭이 생기고 그 글이 들어간다. + +## 운영 + +| 칸 | 용도 | +| --- | --- | +| 답변 | 적으면 페이지에 "널스메이트 팀" 답변으로 보인다. 답변시각은 자동 기록 | +| 공개 | `N`이면 페이지에서 숨긴다. 부적절한 글은 행을 지우지 말고 `N`으로 | + +- 승인 후 공개로 바꾸려면 `Code.gs`의 `REQUIRE_APPROVAL`을 `true`로. +- `Code.gs`를 고친 뒤에는 **배포 → 배포 관리 → 수정 → 새 버전**으로 다시 배포해야 반영된다. 새 배포를 만들면 주소가 바뀌니 기존 배포를 수정한다. diff --git a/sitemap.xml b/sitemap.xml index d302842..55d9745 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,6 +2,7 @@ https://www.nursemate.app/ https://www.nursemate.app/support/ + https://www.nursemate.app/feedback/ https://www.nursemate.app/terms/ https://www.nursemate.app/privacy/ diff --git a/support/index.html b/support/index.html index 7f446ad..dc9baa0 100644 --- a/support/index.html +++ b/support/index.html @@ -24,6 +24,9 @@ .support-contact-action { display:flex; align-items:center; flex-direction:column; gap:9px; flex-shrink:0; } .support-contact-action .button { padding:13px 21px; font-size:15px; gap:18px; border-radius:12px; } .support-email { font-size:12px; color:#527086; text-decoration:underline; text-underline-offset:3px; } + .support-feedback { margin-top:16px; color:#527086; font-size:14px; } + .support-feedback a { color:var(--blue); font-weight:700; } + .support-feedback a:hover { text-decoration:underline; } .support-main { padding-top:62px; padding-bottom:72px; } .support-faq-section { display:grid; grid-template-columns:240px minmax(0,1fr); gap:48px; } .support-section-title { font-size:27px; line-height:1.35; } @@ -104,6 +107,7 @@

무엇을 도와드릴까요?

도움이 필요하면 메일로 문의해 주세요

영업일 기준 2~3일 이내에 답변드리도록 노력하겠습니다.

+

기능 제안이나 사용 후기는 의견 게시판 에 남겨 주셔도 좋아요.

@@ -130,6 +134,6 @@

무엇을 도와드릴까요?

최종 수정일: 2026-10-06

- + From 71e282948ae1e7392374d9a4135fd0202599a934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Park=20hyeon=20gyu=28=EB=B0=94=EA=B2=AC=EA=B7=9C=29?= Date: Thu, 15 Oct 2026 18:36:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=9D=98=EA=B2=AC=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EB=AA=A8=EB=B0=94=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=99=88=20=EB=A7=81=ED=81=AC=EA=B0=80=20=EC=82=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=80=EA=B3=A0=20=EC=A0=9C=EB=AA=A9=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=98=EB=A6=AC=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 390px 폭에서 확인한 두 가지를 고친다. - 헤더의 「홈으로」가 보이지 않았다. landing.css가 모바일에서 `.header-inner nav>a:not(.button)`을 숨기는데, 랜딩의 섹션 메뉴를 접는 규칙이 게시판 헤더 링크에도 걸렸다. 지원 페이지처럼 nav 밖에 두어 로고 말고도 돌아갈 길을 남긴다. - h1이 한 줄로 넘쳐 「들려주세요」가 잘렸다. landing.css의 h1은 nowrap이라 지원 페이지와 같이 white-space:normal로 푼다. Co-Authored-By: Claude Opus 5.5 --- feedback/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feedback/index.html b/feedback/index.html index ba7804a..94e1832 100644 --- a/feedback/index.html +++ b/feedback/index.html @@ -15,7 +15,7 @@ .feedback-header .feedback-nav a:hover { color:var(--blue); } .feedback-hero { background:linear-gradient(120deg,#f0f9ff,#edf9f7); padding:64px 0 54px; } .feedback-hero .eyebrow { margin-bottom:14px; color:var(--blue); } - .feedback-hero h1 { font-size:44px; line-height:1.3; } + .feedback-hero h1 { font-size:44px; white-space:normal; line-height:1.3; } .feedback-intro { margin-top:16px; color:#527086; font-size:17px; line-height:1.8; } .composer { margin-top:34px; padding:24px; background:#fff; border:1px solid #dceaf0; border-radius:22px; box-shadow:0 8px 28px #16394a05; } .composer:focus-within { border-color:#9fd3e8; } @@ -84,7 +84,7 @@