Skip to content

Commit 4c6591d

Browse files
authored
Merge pull request #82 from pirogramming/fix/data-linking
[Fix] 음료추가 직접입력 수정
2 parents 525f94b + fb9793e commit 4c6591d

4 files changed

Lines changed: 127 additions & 18 deletions

File tree

‎config/settings.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,20 @@ def _env_list(key, default):
218218
# collectstatic 수집 위치. 운영에서 nginx가 이 디렉토리를 정적 파일로 서빙한다.
219219
STATIC_ROOT = BASE_DIR / 'staticfiles'
220220

221+
# 운영(DEBUG=False)에서는 파일명에 콘텐츠 해시를 붙여(예: base.a1b2c3.css) 배포마다
222+
# URL이 바뀌게 한다. nginx의 30일 캐시(expires 30d)와 맞물려도 재배포 시 즉시 반영되어
223+
# 강력 새로고침 없이 최신 정적 파일을 받게 된다. 로컬 개발은 collectstatic을 매번
224+
# 돌리지 않으므로 manifest 오류를 피하기 위해 기본 스토리지를 유지한다.
225+
STORAGES = {
226+
"default": {
227+
"BACKEND": "django.core.files.storage.FileSystemStorage",
228+
},
229+
"staticfiles": {
230+
"BACKEND": (
231+
"django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
232+
if not DEBUG
233+
else "django.contrib.staticfiles.storage.StaticFilesStorage"
234+
),
235+
},
236+
}
237+

‎static/css/favorites.css‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
.drink_brand, .drink_name, .drink_size, .drink_caffeine,
2-
.option_item span, .next_btn, .edit_btn {
1+
.drink_brand,
2+
.drink_name,
3+
.drink_size,
4+
.drink_caffeine,
5+
.option_item span,
6+
.next_btn,
7+
.edit_btn {
38
font-family: var(--cd-font-body);
49
}
510

@@ -30,7 +35,9 @@
3035
opacity: 0.35;
3136
}
3237

33-
.drink_info1, .drink_info2, .drink_info3 {
38+
.drink_info1,
39+
.drink_info2,
40+
.drink_info3 {
3441
display: flex;
3542
align-items: center;
3643
gap: 16px;
@@ -162,6 +169,10 @@
162169
height: 150px;
163170
}
164171

172+
.option_list_menu {
173+
height: 120px;
174+
}
175+
165176
.option_item {
166177
display: flex;
167178
flex-direction: column;
@@ -195,7 +206,7 @@
195206

196207
.overlay_step--list .option_list {
197208
grid-template-columns: repeat(3, 1fr);
198-
align-items: start;
209+
align-items: start;
199210
}
200211

201212
.custom_form {
@@ -258,11 +269,17 @@
258269
color: var(--cd-brown-ink);
259270
background: #fff;
260271
}
261-
.option_search:focus { outline: none; border-color: var(--cd-brown-dark); }
272+
273+
.option_search:focus {
274+
outline: none;
275+
border-color: var(--cd-brown-dark);
276+
}
262277

263278
/* .option_item은 display:flex라 브라우저 기본 [hidden]{display:none}보다
264279
우선 적용돼(feed.js의 같은 이슈 참고) hidden 속성만으로는 안 감춰진다. */
265-
.option_item[hidden] { display: none; }
280+
.option_item[hidden] {
281+
display: none;
282+
}
266283

267284
.overlay_step--list .option_item {
268285
padding: 12px 10px;
@@ -325,7 +342,9 @@
325342
height: 110px;
326343
}
327344

328-
.drink_info1, .drink_info2, .drink_info3 {
345+
.drink_info1,
346+
.drink_info2,
347+
.drink_info3 {
329348
padding: 8px 18px;
330349
gap: 12px;
331350
}
@@ -398,7 +417,9 @@
398417
height: 90px;
399418
}
400419

401-
.drink_info1, .drink_info2, .drink_info3 {
420+
.drink_info1,
421+
.drink_info2,
422+
.drink_info3 {
402423
padding: 8px 14px;
403424
gap: 10px;
404425
}

‎static/js/favorites.js‎

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
* POST .../from-preset/(추가) 또는 PATCH .../<id>/(교체)로 저장한다.
55
* 브랜드 단계에서 "직접입력"을 고르면 메뉴/사이즈 프리셋 단계 대신 자유 입력
66
* 폼으로 가고, POST/PATCH .../drinks/(직접입력 전용 엔드포인트)로 저장한다.
7+
* 메뉴(음료명) 단계에서 "직접입력"을 고르면(브랜드는 카탈로그 유지) 이름과
8+
* 카페인량만 직접 입력받고, 사이즈는 그 브랜드의 기존 사이즈 목록을 그대로
9+
* 보여준다 — DrinkSerializer가 브랜드가 custom이 아니면 사이즈를 카탈로그
10+
* 라벨로만 허용하기 때문에(자유 입력 사이즈는 서버에서 거부된다) 이 단계의
11+
* 사이즈는 항상 카탈로그에서 골라야 한다.
712
*/
813
(function () {
914
const listEl = document.getElementById('favorite-drinks-list');
@@ -15,6 +20,7 @@
1520
type: overlay.querySelector('[data-step="1"]'),
1621
brand: overlay.querySelector('[data-step="2"]'),
1722
menu: overlay.querySelector('[data-step="3"]'),
23+
menuCustom: overlay.querySelector('[data-step="menu-custom"]'),
1824
size: overlay.querySelector('[data-step="4"]'),
1925
custom: overlay.querySelector('[data-step="custom"]'),
2026
icon: overlay.querySelector('[data-step="5"]'),
@@ -26,10 +32,16 @@
2632
const customCaffeineInput = document.getElementById('custom-caffeine-input');
2733
const customNextBtn = document.getElementById('wizard-custom-next-btn');
2834

35+
const customMenuNameInput = document.getElementById('custom-menu-name-input');
36+
const customMenuCaffeineInput = document.getElementById('custom-menu-caffeine-input');
37+
const menuCustomNextBtn = document.getElementById('wizard-menu-custom-next-btn');
38+
39+
const MENU_CUSTOM_VALUE = '__custom__'; // 실제 메뉴명과 겹치지 않도록 예약된 값
40+
2941
let wizard = {
3042
mode: 'add', editingId: null,
3143
type: null, brand: null, name: null, size: null, iconKey: null,
32-
customBrandName: '', caffeineMg: null,
44+
nameIsCustom: false, customBrandName: '', caffeineMg: null,
3345
presetsForType: [],
3446
};
3547

@@ -133,7 +145,7 @@
133145
wizard = {
134146
mode, editingId: editingId ?? null,
135147
type: null, brand: null, name: null, size: null, iconKey: null,
136-
customBrandName: '', caffeineMg: null,
148+
nameIsCustom: false, customBrandName: '', caffeineMg: null,
137149
presetsForType: [],
138150
};
139151
steps.type.querySelectorAll('.option_item').forEach((el) => el.classList.remove('is-selected'));
@@ -145,6 +157,8 @@
145157
customNameInput.value = '';
146158
customSizeInput.value = '';
147159
customCaffeineInput.value = '';
160+
customMenuNameInput.value = '';
161+
customMenuCaffeineInput.value = '';
148162
showStep('type');
149163
overlay.style.display = 'flex';
150164
}
@@ -160,7 +174,7 @@
160174
const seen = new Map();
161175
wizard.presetsForType.forEach((p) => { if (!seen.has(p.brand)) seen.set(p.brand, p.brand_name); });
162176
const options = [...seen.entries()].map(([value, label]) => ({ value, label }));
163-
options.push({ value: 'custom', label: '직접입력' });
177+
options.unshift({ value: 'custom', label: '직접입력' });
164178
renderOptionList(steps.brand, options, (value) => {
165179
wizard.brand = value;
166180
});
@@ -170,8 +184,11 @@
170184
const names = [...new Set(
171185
wizard.presetsForType.filter((p) => p.brand === wizard.brand).map((p) => p.name)
172186
)];
173-
renderOptionList(steps.menu, names.map((n) => ({ value: n, label: n })), (value) => {
174-
wizard.name = value;
187+
const options = names.map((n) => ({ value: n, label: n }));
188+
options.unshift({ value: MENU_CUSTOM_VALUE, label: '직접입력' });
189+
renderOptionList(steps.menu, options, (value) => {
190+
wizard.nameIsCustom = value === MENU_CUSTOM_VALUE;
191+
wizard.name = wizard.nameIsCustom ? null : value;
175192
});
176193
// 새로 그린 목록이라 전부 보이는 상태 — 검색창만 비워서 이전 검색어가 안 남게 한다.
177194
if (menuSearchInput) menuSearchInput.value = '';
@@ -182,6 +199,7 @@
182199
menuSearchInput?.addEventListener('input', () => {
183200
const q = menuSearchInput.value.trim().toLowerCase();
184201
steps.menu.querySelectorAll('.option_item').forEach((item) => {
202+
if (item.dataset.value === MENU_CUSTOM_VALUE) return; // "직접입력"은 검색과 무관하게 항상 노출
185203
item.hidden = q.length > 0 && !item.textContent.trim().toLowerCase().includes(q);
186204
});
187205
});
@@ -195,6 +213,20 @@
195213
);
196214
}
197215

216+
// 메뉴가 직접입력이면 특정 이름에 매칭되는 프리셋이 없으므로, 사이즈는 그
217+
// 브랜드가 취급하는 사이즈 라벨 전체(이름 무관)에서 고른다. 카페인량은
218+
// 프리셋에 없으니 라벨에 mg를 붙이지 않는다 — 사용자가 직접 입력한 값을 쓴다.
219+
function loadSizeOptionsForBrand() {
220+
const sizes = [...new Set(
221+
wizard.presetsForType.filter((p) => p.brand === wizard.brand).map((p) => p.size)
222+
)].filter(Boolean);
223+
renderOptionList(
224+
steps.size,
225+
sizes.map((s) => ({ value: s, label: s })),
226+
(value) => { wizard.size = value; }
227+
);
228+
}
229+
198230
function loadIconOptions() {
199231
const options = window.CD_DRINK_ICON_OPTIONS[wizard.type] || window.CD_DRINK_ICON_OPTIONS.other;
200232
renderIconOptionList(steps.icon, options, (key) => {
@@ -204,11 +236,15 @@
204236

205237
async function saveDrink() {
206238
let res;
207-
if (wizard.brand === 'custom') {
239+
if (wizard.brand === 'custom' || wizard.nameIsCustom) {
240+
// 브랜드 전체 직접입력이거나, 브랜드는 카탈로그인데 메뉴명만 직접입력인 경우.
241+
// 두 경우 다 카페인량을 서버 프리셋에서 조회할 수 없으므로 사용자가 입력한
242+
// 값을 그대로 쓴다. custom_brand_name은 brand=custom일 때만 의미가 있다
243+
// (DrinkSerializer.validate가 그 외엔 어차피 빈 값으로 정리하지만 명시한다).
208244
const payload = {
209245
type: wizard.type,
210-
brand: 'custom',
211-
custom_brand_name: wizard.customBrandName,
246+
brand: wizard.brand,
247+
custom_brand_name: wizard.brand === 'custom' ? wizard.customBrandName : '',
212248
name: wizard.name,
213249
size: wizard.size,
214250
caffeine_mg: wizard.caffeineMg,
@@ -293,11 +329,29 @@
293329
});
294330

295331
steps.menu.querySelector('.next_btn').addEventListener('click', () => {
296-
if (!wizard.name) { alert('메뉴를 선택해주세요.'); return; }
332+
if (!wizard.name && !wizard.nameIsCustom) { alert('메뉴를 선택해주세요.'); return; }
333+
if (wizard.nameIsCustom) {
334+
showStep('menuCustom');
335+
return;
336+
}
297337
loadSizeOptions();
298338
showStep('size');
299339
});
300340

341+
menuCustomNextBtn.addEventListener('click', () => {
342+
const name = customMenuNameInput.value.trim();
343+
const caffeine = Number(customMenuCaffeineInput.value);
344+
if (!name) { alert('음료 이름을 입력해주세요.'); return; }
345+
if (!customMenuCaffeineInput.value || !(caffeine > 0 && caffeine <= 1000)) {
346+
alert('카페인 함량을 1~1000mg 사이로 입력해주세요.');
347+
return;
348+
}
349+
wizard.name = name;
350+
wizard.caffeineMg = caffeine;
351+
loadSizeOptionsForBrand();
352+
showStep('size');
353+
});
354+
301355
steps.size.querySelector('.next_btn').addEventListener('click', () => {
302356
if (!wizard.size) { alert('사이즈를 선택해주세요.'); return; }
303357
loadIconOptions();

‎templates/diary/favorites.html‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,27 @@ <h3>어떤 브랜드의 음료인가요?</h3>
5353
<div class="overlay_step overlay_step--list" data-step="3" style="display:none;">
5454
<h3>어떤 메뉴인가요?</h3>
5555
<input type="text" class="option_search" data-option-search placeholder="메뉴 검색" autocomplete="off">
56-
<div class="option_list"></div>
56+
<div class="option_list option_list_menu"></div>
5757
<button type="button" class="next_btn" data-next="size">다음</button>
5858
</div>
5959

60+
<!-- 3단계에서 "직접입력"을 고르면 여기로 온다. 브랜드는 카탈로그 그대로라
61+
사이즈는 다음 단계(4단계)에서 그 브랜드의 기존 사이즈 목록을 그대로 보여준다. -->
62+
<div class="overlay_step overlay_step--custom" data-step="menu-custom" style="display:none;">
63+
<h3>음료 정보를 입력해주세요</h3>
64+
<div class="custom_form">
65+
<label>
66+
<span>음료 이름</span>
67+
<input type="text" id="custom-menu-name-input" placeholder="예: 아메리카노" maxlength="100" autocomplete="off">
68+
</label>
69+
<label>
70+
<span>카페인 함량 (mg)</span>
71+
<input type="number" id="custom-menu-caffeine-input" placeholder="예: 150" min="1" max="1000" inputmode="numeric">
72+
</label>
73+
</div>
74+
<button type="button" class="next_btn" id="wizard-menu-custom-next-btn">다음</button>
75+
</div>
76+
6077
<!-- 4단계: 사이즈 선택 -->
6178
<div class="overlay_step overlay_step--list" data-step="4" style="display:none;">
6279
<h3>어떤 사이즈로 마시나요?</h3>

0 commit comments

Comments
 (0)