|
4 | 4 | * POST .../from-preset/(추가) 또는 PATCH .../<id>/(교체)로 저장한다. |
5 | 5 | * 브랜드 단계에서 "직접입력"을 고르면 메뉴/사이즈 프리셋 단계 대신 자유 입력 |
6 | 6 | * 폼으로 가고, POST/PATCH .../drinks/(직접입력 전용 엔드포인트)로 저장한다. |
| 7 | + * 메뉴(음료명) 단계에서 "직접입력"을 고르면(브랜드는 카탈로그 유지) 이름과 |
| 8 | + * 카페인량만 직접 입력받고, 사이즈는 그 브랜드의 기존 사이즈 목록을 그대로 |
| 9 | + * 보여준다 — DrinkSerializer가 브랜드가 custom이 아니면 사이즈를 카탈로그 |
| 10 | + * 라벨로만 허용하기 때문에(자유 입력 사이즈는 서버에서 거부된다) 이 단계의 |
| 11 | + * 사이즈는 항상 카탈로그에서 골라야 한다. |
7 | 12 | */ |
8 | 13 | (function () { |
9 | 14 | const listEl = document.getElementById('favorite-drinks-list'); |
|
15 | 20 | type: overlay.querySelector('[data-step="1"]'), |
16 | 21 | brand: overlay.querySelector('[data-step="2"]'), |
17 | 22 | menu: overlay.querySelector('[data-step="3"]'), |
| 23 | + menuCustom: overlay.querySelector('[data-step="menu-custom"]'), |
18 | 24 | size: overlay.querySelector('[data-step="4"]'), |
19 | 25 | custom: overlay.querySelector('[data-step="custom"]'), |
20 | 26 | icon: overlay.querySelector('[data-step="5"]'), |
|
26 | 32 | const customCaffeineInput = document.getElementById('custom-caffeine-input'); |
27 | 33 | const customNextBtn = document.getElementById('wizard-custom-next-btn'); |
28 | 34 |
|
| 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 | + |
29 | 41 | let wizard = { |
30 | 42 | mode: 'add', editingId: null, |
31 | 43 | type: null, brand: null, name: null, size: null, iconKey: null, |
32 | | - customBrandName: '', caffeineMg: null, |
| 44 | + nameIsCustom: false, customBrandName: '', caffeineMg: null, |
33 | 45 | presetsForType: [], |
34 | 46 | }; |
35 | 47 |
|
|
133 | 145 | wizard = { |
134 | 146 | mode, editingId: editingId ?? null, |
135 | 147 | type: null, brand: null, name: null, size: null, iconKey: null, |
136 | | - customBrandName: '', caffeineMg: null, |
| 148 | + nameIsCustom: false, customBrandName: '', caffeineMg: null, |
137 | 149 | presetsForType: [], |
138 | 150 | }; |
139 | 151 | steps.type.querySelectorAll('.option_item').forEach((el) => el.classList.remove('is-selected')); |
|
145 | 157 | customNameInput.value = ''; |
146 | 158 | customSizeInput.value = ''; |
147 | 159 | customCaffeineInput.value = ''; |
| 160 | + customMenuNameInput.value = ''; |
| 161 | + customMenuCaffeineInput.value = ''; |
148 | 162 | showStep('type'); |
149 | 163 | overlay.style.display = 'flex'; |
150 | 164 | } |
|
160 | 174 | const seen = new Map(); |
161 | 175 | wizard.presetsForType.forEach((p) => { if (!seen.has(p.brand)) seen.set(p.brand, p.brand_name); }); |
162 | 176 | const options = [...seen.entries()].map(([value, label]) => ({ value, label })); |
163 | | - options.push({ value: 'custom', label: '직접입력' }); |
| 177 | + options.unshift({ value: 'custom', label: '직접입력' }); |
164 | 178 | renderOptionList(steps.brand, options, (value) => { |
165 | 179 | wizard.brand = value; |
166 | 180 | }); |
|
170 | 184 | const names = [...new Set( |
171 | 185 | wizard.presetsForType.filter((p) => p.brand === wizard.brand).map((p) => p.name) |
172 | 186 | )]; |
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; |
175 | 192 | }); |
176 | 193 | // 새로 그린 목록이라 전부 보이는 상태 — 검색창만 비워서 이전 검색어가 안 남게 한다. |
177 | 194 | if (menuSearchInput) menuSearchInput.value = ''; |
|
182 | 199 | menuSearchInput?.addEventListener('input', () => { |
183 | 200 | const q = menuSearchInput.value.trim().toLowerCase(); |
184 | 201 | steps.menu.querySelectorAll('.option_item').forEach((item) => { |
| 202 | + if (item.dataset.value === MENU_CUSTOM_VALUE) return; // "직접입력"은 검색과 무관하게 항상 노출 |
185 | 203 | item.hidden = q.length > 0 && !item.textContent.trim().toLowerCase().includes(q); |
186 | 204 | }); |
187 | 205 | }); |
|
195 | 213 | ); |
196 | 214 | } |
197 | 215 |
|
| 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 | + |
198 | 230 | function loadIconOptions() { |
199 | 231 | const options = window.CD_DRINK_ICON_OPTIONS[wizard.type] || window.CD_DRINK_ICON_OPTIONS.other; |
200 | 232 | renderIconOptionList(steps.icon, options, (key) => { |
|
204 | 236 |
|
205 | 237 | async function saveDrink() { |
206 | 238 | 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가 그 외엔 어차피 빈 값으로 정리하지만 명시한다). |
208 | 244 | const payload = { |
209 | 245 | 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 : '', |
212 | 248 | name: wizard.name, |
213 | 249 | size: wizard.size, |
214 | 250 | caffeine_mg: wizard.caffeineMg, |
|
293 | 329 | }); |
294 | 330 |
|
295 | 331 | 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 | + } |
297 | 337 | loadSizeOptions(); |
298 | 338 | showStep('size'); |
299 | 339 | }); |
300 | 340 |
|
| 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 | + |
301 | 355 | steps.size.querySelector('.next_btn').addEventListener('click', () => { |
302 | 356 | if (!wizard.size) { alert('사이즈를 선택해주세요.'); return; } |
303 | 357 | loadIconOptions(); |
|
0 commit comments