ICGenAIShare06 commited on
Commit
2a7ba3c
·
verified ·
1 Parent(s): 39b529b

Uploading Pipeline with requirements

Browse files

Uploaded app.py for pipeline implementation along with other requirements.

Files changed (4) hide show
  1. app.py +1352 -0
  2. requirements.txt +6 -0
  3. templates/dark.html +470 -0
  4. templates/warm.html +566 -0
app.py ADDED
@@ -0,0 +1,1352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ShopSite AI - Small Business Website Generator
3
+ Block-based page builder: users compose pages from reusable blocks,
4
+ LLM (Qwen Coder) rewrites individual block HTML on natural language instruction.
5
+ """
6
+
7
+ import gradio as gr
8
+ from PIL import Image, ImageDraw, ImageFont
9
+ import json
10
+ import zipfile
11
+ import os
12
+ import base64
13
+ import io
14
+ import re
15
+ import shutil
16
+ import uuid
17
+ import urllib.parse
18
+ from pathlib import Path
19
+
20
+ try:
21
+ import torch
22
+ from transformers import AutoModelForCausalLM, AutoTokenizer
23
+ TORCH_AVAILABLE = True
24
+ except ImportError:
25
+ TORCH_AVAILABLE = False
26
+ print("⚠️ torch/transformers not installed. LLM features disabled.")
27
+
28
+ try:
29
+ from diffusers import AutoPipelineForText2Image
30
+ SD_AVAILABLE = True and TORCH_AVAILABLE
31
+ except (ImportError, RuntimeError):
32
+ SD_AVAILABLE = False
33
+ print("⚠️ diffusers not available. Poster generation disabled.")
34
+
35
+ # ============================================================
36
+ # CONFIG
37
+ # ============================================================
38
+ QWEN_MODEL = "Qwen/Qwen2.5-7B-Instruct"
39
+ QWEN_CODER_MODEL = "Qwen/Qwen2.5-Coder-14B-Instruct"
40
+ SD_MODEL_ID = "stabilityai/sd-turbo"
41
+
42
+ WORK_DIR = Path("./workspace"); WORK_DIR.mkdir(exist_ok=True)
43
+ TEMPLATE_DIR = Path("./templates")
44
+
45
+ # ============================================================
46
+ # GLOBAL STATE
47
+ # ============================================================
48
+ current_html = ""
49
+ current_menu_data = {}
50
+ current_site_info = {}
51
+ current_template_key = "warm"
52
+ page_blocks = [] # [{"id": str, "type": str, "html": str}, ...]
53
+ sd_pipe = None
54
+
55
+ # Menu item HTML template — LLM can rewrite this to change structure
56
+ # Placeholders: {name}, {price}, {img_tag}
57
+ MENU_ITEM_TEMPLATE_DEFAULT = """\
58
+ <div class="menu-item" data-item-name="{name}">
59
+ {img_tag}
60
+ <div class="menu-item-info">
61
+ <div class="menu-item-name">{name}</div>
62
+ <div class="menu-item-price">{price}</div>
63
+ </div>
64
+ </div>"""
65
+ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT
66
+
67
+ # ============================================================
68
+ # BLOCK DEFAULTS (warm-theme HTML that works with CSS variables)
69
+ # ============================================================
70
+ BLOCK_DEFAULTS = {
71
+ "Hero Banner": """\
72
+ <div class="hero">
73
+ <div class="hero-badge">
74
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
75
+ Est. 2024
76
+ </div>
77
+ <h1>Shop Name</h1>
78
+ <p class="hero-tagline">Welcome to our shop</p>
79
+ </div>
80
+ <div class="info-pills">
81
+ <div class="pill">
82
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
83
+ <span>Open Daily</span>
84
+ </div>
85
+ <div class="pill">
86
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
87
+ <span>Visit Us</span>
88
+ </div>
89
+ </div>""",
90
+
91
+ "Promo / Event": """\
92
+ <div class="section-title">
93
+ <h2>What's New</h2>
94
+ <div class="line"></div>
95
+ </div>
96
+ <div class="promo-banner">
97
+ <div class="promo-placeholder">
98
+ <span>Coming Soon</span>
99
+ <small>Stay tuned for updates</small>
100
+ </div>
101
+ </div>""",
102
+
103
+ "About / Story": """\
104
+ <div class="section-title">
105
+ <h2>Our Story</h2>
106
+ <div class="line"></div>
107
+ </div>
108
+ <div class="about-card">
109
+ <p>Welcome to our shop. We are passionate about quality and great service. Come visit us and experience the difference.</p>
110
+ </div>""",
111
+
112
+ "Contact Info": """\
113
+ <div class="section-title">
114
+ <h2>Find Us</h2>
115
+ <div class="line"></div>
116
+ </div>
117
+ <div class="contact-section">
118
+ <a class="contact-item" href="">
119
+ <div class="contact-icon">
120
+ <svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
121
+ </div>
122
+ <div class="contact-text"><div class="label">Phone</div><div class="value">—</div></div>
123
+ </a>
124
+ <div class="contact-item">
125
+ <div class="contact-icon">
126
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
127
+ </div>
128
+ <div class="contact-text"><div class="label">Address</div><div class="value">—</div></div>
129
+ </div>
130
+ <div class="contact-item">
131
+ <div class="contact-icon">
132
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
133
+ </div>
134
+ <div class="contact-text"><div class="label">Hours</div><div class="value">—</div></div>
135
+ </div>
136
+ </div>
137
+ <div class="spacer-lg"></div>""",
138
+
139
+ "Announcement": """\
140
+ <div style="margin:16px 20px;">
141
+ <div style="background:var(--bg-warm,#F5EDE3);border-radius:var(--card-radius,18px);padding:20px 24px;border-left:4px solid var(--primary);">
142
+ <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-muted);font-weight:700;margin-bottom:8px;">📢 Notice</div>
143
+ <p style="font-size:15px;line-height:1.6;color:var(--text);">Add your announcement here.</p>
144
+ </div>
145
+ </div>""",
146
+
147
+ "Menu Preview": """\
148
+ <div class="section-title">
149
+ <h2>Our Menu</h2>
150
+ <div class="line"></div>
151
+ </div>
152
+ <div style="padding:0 24px 16px;text-align:center;">
153
+ <button onclick="switchPage('menu')" style="background:var(--primary);color:var(--bg);border:none;padding:12px 32px;border-radius:100px;font-size:14px;font-weight:600;cursor:pointer;letter-spacing:0.04em;">View Full Menu →</button>
154
+ </div>""",
155
+ }
156
+
157
+ # ============================================================
158
+ # BLOCK HELPERS
159
+ # ============================================================
160
+ def _uid():
161
+ return str(uuid.uuid4())[:6]
162
+
163
+ def _block_label(block):
164
+ return f"{block['type']} [{block['id']}]"
165
+
166
+ def _find_block(label):
167
+ for b in page_blocks:
168
+ if _block_label(b) == label:
169
+ return b
170
+ return None
171
+
172
+ def get_block_choices():
173
+ return [_block_label(b) for b in page_blocks]
174
+
175
+ # ============================================================
176
+ # HuggingFace LOCAL INFERENCE
177
+ # ============================================================
178
+ _hf_models = {}
179
+
180
+ def load_hf_model(model_id):
181
+ if model_id not in _hf_models:
182
+ print(f"Loading {model_id}...")
183
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
184
+ model = AutoModelForCausalLM.from_pretrained(
185
+ model_id,
186
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
187
+ device_map="auto",
188
+ )
189
+ _hf_models[model_id] = (tokenizer, model)
190
+ print(f"✅ {model_id} loaded.")
191
+ return _hf_models[model_id]
192
+
193
+ def ollama_chat(model, system_prompt, user_message, temperature=0.3):
194
+ if not TORCH_AVAILABLE:
195
+ return "ERROR: torch/transformers not installed."
196
+ try:
197
+ tokenizer, hf_model = load_hf_model(model)
198
+ messages = [
199
+ {"role": "system", "content": system_prompt},
200
+ {"role": "user", "content": user_message},
201
+ ]
202
+ text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
203
+ inputs = tokenizer(text, return_tensors="pt").to(hf_model.device)
204
+ max_new_tokens = 1024 if model == QWEN_CODER_MODEL else 512
205
+ with torch.no_grad():
206
+ outputs = hf_model.generate(
207
+ **inputs,
208
+ max_new_tokens=max_new_tokens,
209
+ temperature=temperature if temperature > 0 else None,
210
+ do_sample=temperature > 0,
211
+ pad_token_id=tokenizer.eos_token_id,
212
+ )
213
+ return tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
214
+ except Exception as e:
215
+ return f"ERROR: {e}"
216
+
217
+ def parse_json_from_response(text):
218
+ m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
219
+ if m:
220
+ text = m.group(1)
221
+ try:
222
+ return json.loads(text.strip())
223
+ except json.JSONDecodeError:
224
+ m2 = re.search(r'\{.*\}', text, re.DOTALL)
225
+ if m2:
226
+ try:
227
+ return json.loads(m2.group())
228
+ except json.JSONDecodeError:
229
+ pass
230
+ return {}
231
+
232
+ # ============================================================
233
+ # MENU ZIP
234
+ # ============================================================
235
+ def process_menu_zip(zip_file):
236
+ menu = {}
237
+ if zip_file is None:
238
+ return menu
239
+ extract_dir = WORK_DIR / "menu_images"
240
+ if extract_dir.exists():
241
+ shutil.rmtree(extract_dir)
242
+ extract_dir.mkdir(parents=True)
243
+ with zipfile.ZipFile(zip_file, 'r') as zf:
244
+ zf.extractall(extract_dir)
245
+ for root, dirs, files in os.walk(extract_dir):
246
+ rel = Path(root).relative_to(extract_dir)
247
+ if str(rel).startswith(('__', '.')):
248
+ continue
249
+ for fname in sorted(files):
250
+ if fname.startswith(('.', '__')):
251
+ continue
252
+ if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
253
+ continue
254
+ fpath = Path(root) / fname
255
+ parts = fpath.relative_to(extract_dir).parts
256
+ category = parts[-2] if len(parts) >= 2 else "Menu"
257
+ stem = fpath.stem
258
+ last_us = stem.rfind('_')
259
+ if last_us > 0:
260
+ name_part = stem[:last_us].replace('_', ' ').strip()
261
+ try:
262
+ price = float(stem[last_us + 1:].strip())
263
+ except ValueError:
264
+ name_part = stem.replace('_', ' ').strip(); price = 0.0
265
+ else:
266
+ name_part = stem.replace('_', ' ').strip(); price = 0.0
267
+ with open(fpath, 'rb') as f:
268
+ img_bytes = f.read()
269
+ img_b64 = base64.b64encode(img_bytes).decode('utf-8')
270
+ ext = fpath.suffix.lower()
271
+ mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'}
272
+ if category not in menu:
273
+ menu[category] = []
274
+ menu[category].append({
275
+ "name": name_part, "price": price,
276
+ "image_base64": f"data:{mime.get(ext,'image/png')};base64,{img_b64}",
277
+ })
278
+ return menu
279
+
280
+ # ============================================================
281
+ # MENU HTML BUILDERS
282
+ # ============================================================
283
+ def build_category_tabs(menu_data):
284
+ return "\n ".join(
285
+ f'<div class="cat-tab" data-cat="{cat}">{cat}</div>'
286
+ for cat in menu_data.keys()
287
+ )
288
+
289
+ def build_menu_html(menu_data):
290
+ html = ""
291
+ for cat, items in menu_data.items():
292
+ html += f' <div class="menu-category" data-cat="{cat}">\n'
293
+ html += f' <div class="menu-category-title">{cat}</div>\n'
294
+ for item in items:
295
+ price_str = f"${item['price']:.2f}" if item['price'] > 0 else ""
296
+ img_src = item.get('image_base64', '')
297
+ img_tag = (f'<img class="menu-item-img" src="{img_src}" alt="{item["name"]}" loading="lazy">'
298
+ if img_src else
299
+ '<div class="menu-item-img" style="background:linear-gradient(135deg,var(--secondary),var(--primary));opacity:0.3;"></div>')
300
+ html += (current_menu_item_template
301
+ .replace("{name}", item["name"])
302
+ .replace("{price}", price_str)
303
+ .replace("{img_tag}", img_tag)
304
+ .replace("{img_src}", img_src)
305
+ .replace("{description}", item.get("description", "")) + "\n")
306
+ html += ' </div>\n'
307
+ return html
308
+
309
+ # ============================================================
310
+ # TEMPLATE ENGINE
311
+ # ============================================================
312
+ def load_template(key):
313
+ path = TEMPLATE_DIR / f"{key}.html"
314
+ if not path.exists():
315
+ path = TEMPLATE_DIR / "warm.html"
316
+ return path.read_text(encoding='utf-8')
317
+
318
+ def rebuild_html():
319
+ global current_html
320
+ template = load_template(current_template_key)
321
+ home_html = "\n".join(b["html"] for b in page_blocks)
322
+
323
+ html = template
324
+ html = html.replace("<!-- {{HOME_BLOCKS}} -->", home_html)
325
+ html = html.replace("<!-- {{CATEGORY_TABS}} -->", build_category_tabs(current_menu_data))
326
+ html = html.replace("<!-- {{MENU_ITEMS}} -->", build_menu_html(current_menu_data))
327
+ html = html.replace("{{SHOP_NAME}}", current_site_info.get("shop_name", "My Shop"))
328
+
329
+ # Poster carousel injection (into any .promo-placeholder found in blocks)
330
+ posters = current_site_info.get("posters", [])
331
+ if posters:
332
+ if len(posters) == 1:
333
+ carousel_html = f'<img src="{posters[0]}" alt="Poster" style="width:100%;height:auto;display:block;">'
334
+ else:
335
+ slides = "\n".join(
336
+ f'<div class="ps-slide{" ps-active" if i==0 else ""}"><img src="{p}" alt="Poster {i+1}" style="width:100%;height:auto;display:block;"></div>'
337
+ for i, p in enumerate(posters)
338
+ )
339
+ dots = "\n".join(
340
+ f'<span class="ps-dot{" ps-dot-on" if i==0 else ""}" onclick="psGo({i})"></span>'
341
+ for i in range(len(posters))
342
+ )
343
+ carousel_html = (
344
+ f'<div class="ps-wrap">{slides}'
345
+ f'<button class="ps-btn ps-l" onclick="psMove(-1)">&#10094;</button>'
346
+ f'<button class="ps-btn ps-r" onclick="psMove(1)">&#10095;</button>'
347
+ f'<div class="ps-dots">{dots}</div></div>'
348
+ )
349
+ pat = r'<div class="promo-placeholder"[^>]*>.*?</div>'
350
+ html = re.sub(pat, carousel_html, html, flags=re.DOTALL, count=1)
351
+ carousel_css = (
352
+ ".ps-wrap{position:relative;overflow:hidden;border-radius:var(--card-radius,12px);}"
353
+ ".ps-slide{display:none;}.ps-slide.ps-active{display:block;}"
354
+ ".ps-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.45);"
355
+ "color:#fff;border:none;padding:10px 14px;font-size:18px;cursor:pointer;z-index:10;border-radius:6px;}"
356
+ ".ps-l{left:8px;}.ps-r{right:8px;}"
357
+ ".ps-dots{position:absolute;bottom:10px;width:100%;text-align:center;}"
358
+ ".ps-dot{display:inline-block;width:8px;height:8px;background:rgba(255,255,255,0.5);"
359
+ "border-radius:50%;margin:0 3px;cursor:pointer;}"
360
+ ".ps-dot.ps-dot-on{background:#fff;}"
361
+ )
362
+ carousel_js = (
363
+ "<script>(function(){var idx=0;"
364
+ "function show(n){var s=document.querySelectorAll('.ps-slide');"
365
+ "var d=document.querySelectorAll('.ps-dot');if(!s.length)return;"
366
+ "idx=(n+s.length)%s.length;"
367
+ "s.forEach(function(e){e.classList.remove('ps-active');});"
368
+ "d.forEach(function(e){e.classList.remove('ps-dot-on');});"
369
+ "s[idx].classList.add('ps-active');"
370
+ "if(d[idx])d[idx].classList.add('ps-dot-on');}"
371
+ "window.psMove=function(d){show(idx+d);};"
372
+ "window.psGo=function(n){show(n);};"
373
+ "})();</script>"
374
+ )
375
+ html = html.replace("</style>", f"\n/* Carousel */\n{carousel_css}\n</style>", 1)
376
+ html = html.replace("</body>", f"\n{carousel_js}\n</body>", 1)
377
+
378
+ # Custom CSS overrides
379
+ css = current_site_info.get("custom_css", "")
380
+ if css:
381
+ html = html.replace("</style>", f"\n/* Custom */\n{css}\n</style>", 1)
382
+
383
+ current_html = html
384
+ return html
385
+
386
+ # ============================================================
387
+ # BLOCK MANAGEMENT HANDLERS
388
+ # ============================================================
389
+ def _make_menu_preview_html():
390
+ if not current_menu_data:
391
+ return BLOCK_DEFAULTS["Menu Preview"]
392
+ preview_items = []
393
+ for items in current_menu_data.values():
394
+ for item in items:
395
+ preview_items.append(item)
396
+ if len(preview_items) >= 3:
397
+ break
398
+ if len(preview_items) >= 3:
399
+ break
400
+ rows = ""
401
+ for item in preview_items:
402
+ price_str = f"${item['price']:.2f}" if item.get('price', 0) > 0 else ""
403
+ img_src = item.get('image_base64', '')
404
+ img_html = (f'<img src="{img_src}" alt="{item["name"]}" style="width:56px;height:56px;border-radius:10px;object-fit:cover;flex-shrink:0;">'
405
+ if img_src else
406
+ '<div style="width:56px;height:56px;border-radius:10px;background:linear-gradient(135deg,var(--secondary),var(--primary));opacity:0.4;flex-shrink:0;"></div>')
407
+ rows += f""" <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);">
408
+ {img_html}
409
+ <div>
410
+ <div style="font-size:13px;font-weight:600;color:var(--text);">{item['name']}</div>
411
+ <div style="font-size:13px;color:var(--primary);font-weight:600;">{price_str}</div>
412
+ </div>
413
+ </div>\n"""
414
+ return f"""\
415
+ <div class="section-title">
416
+ <h2>Our Menu</h2>
417
+ <div class="line"></div>
418
+ </div>
419
+ <div style="margin:0 24px;padding:0 20px;background:var(--bg-card);border-radius:var(--card-radius);border:1px solid var(--border);">
420
+ {rows} <div style="padding:14px 0;text-align:center;">
421
+ <button onclick="switchPage('menu')" style="background:var(--primary);color:var(--bg);border:none;padding:10px 28px;border-radius:100px;font-size:13px;font-weight:600;cursor:pointer;">View Full Menu →</button>
422
+ </div>
423
+ </div>"""
424
+
425
+ def handle_add_block(block_type, selected_label):
426
+ if block_type == "Menu Preview":
427
+ initial_html = _make_menu_preview_html()
428
+ else:
429
+ initial_html = BLOCK_DEFAULTS.get(block_type, "")
430
+ new_block = {"id": _uid(), "type": block_type, "html": initial_html}
431
+ if selected_label and _find_block(selected_label):
432
+ idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1)
433
+ page_blocks.insert(idx + 1, new_block)
434
+ else:
435
+ page_blocks.append(new_block)
436
+ rebuild_html()
437
+ choices = get_block_choices()
438
+ new_label = _block_label(new_block)
439
+ return preview(current_html), gr.update(choices=choices, value=new_label)
440
+
441
+ def handle_remove_block(selected_label):
442
+ global page_blocks
443
+ if not selected_label:
444
+ return preview(current_html), gr.update()
445
+ page_blocks = [b for b in page_blocks if _block_label(b) != selected_label]
446
+ rebuild_html()
447
+ choices = get_block_choices()
448
+ return preview(current_html), gr.update(choices=choices, value=choices[0] if choices else None)
449
+
450
+ def handle_move_up(selected_label):
451
+ idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1)
452
+ if idx > 0:
453
+ page_blocks[idx - 1], page_blocks[idx] = page_blocks[idx], page_blocks[idx - 1]
454
+ rebuild_html()
455
+ return preview(current_html), gr.update(choices=get_block_choices(), value=selected_label)
456
+
457
+ def handle_move_down(selected_label):
458
+ idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1)
459
+ if 0 <= idx < len(page_blocks) - 1:
460
+ page_blocks[idx], page_blocks[idx + 1] = page_blocks[idx + 1], page_blocks[idx]
461
+ rebuild_html()
462
+ return preview(current_html), gr.update(choices=get_block_choices(), value=selected_label)
463
+
464
+ # ============================================================
465
+ # LLM BLOCK EDITOR
466
+ # ============================================================
467
+ CODER_SYSTEM = """You are a frontend developer editing a mobile website HTML block.
468
+
469
+ RULES:
470
+ - Output ONLY the modified HTML. No explanation, no markdown fences, no ```html.
471
+ - Keep the mobile-friendly layout and existing CSS classes.
472
+ - Only change what the instruction asks.
473
+ - Do NOT output <html>, <head>, <body>, or <style> tags — only the inner block content.
474
+ - You may add inline styles for visual adjustments.
475
+ - Preserve existing CSS class names and structure unless explicitly asked to change them."""
476
+
477
+ def edit_block_with_llm(selected_label, instruction, chat_history):
478
+ chat_history = chat_history or []
479
+
480
+ if not current_html:
481
+ chat_history.append({"role": "assistant", "content": "⚠️ Generate a website first in the Create tab."})
482
+ return chat_history, preview(current_html)
483
+
484
+ if not selected_label:
485
+ chat_history.append({"role": "assistant", "content": "⚠️ Select a block from the list first."})
486
+ return chat_history, preview(current_html)
487
+
488
+ block = _find_block(selected_label)
489
+ if not block:
490
+ chat_history.append({"role": "assistant", "content": "❌ Block not found."})
491
+ return chat_history, preview(current_html)
492
+
493
+ chat_history.append({"role": "user", "content": f"[{block['type']}] {instruction}"})
494
+
495
+ prompt = f"""Current HTML block:
496
+ {block['html']}
497
+
498
+ Instruction: {instruction}
499
+
500
+ Output the modified HTML block:"""
501
+
502
+ raw = ollama_chat(QWEN_CODER_MODEL, CODER_SYSTEM, prompt, temperature=0.3)
503
+
504
+ if raw.startswith("ERROR:"):
505
+ chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
506
+ return chat_history, preview(current_html)
507
+
508
+ new_html = raw.strip()
509
+ new_html = re.sub(r'^```html?\s*\n?', '', new_html)
510
+ new_html = re.sub(r'\n?```\s*$', '', new_html)
511
+
512
+ if '<!DOCTYPE' in new_html or '<html' in new_html.lower():
513
+ chat_history.append({"role": "assistant", "content": "❌ Model returned full page. Try a simpler instruction."})
514
+ return chat_history, preview(current_html)
515
+
516
+ block["html"] = new_html
517
+ rebuild_html()
518
+ chat_history.append({"role": "assistant", "content": f"✅ {block['type']} updated!"})
519
+ return chat_history, preview(current_html)
520
+
521
+ # ============================================================
522
+ # CUSTOM BLOCK GENERATOR
523
+ # ============================================================
524
+ CUSTOM_BLOCK_SYSTEM = """You are generating a new HTML block for a mobile-first restaurant/shop website.
525
+ Generate a single self-contained HTML block based on the user's description.
526
+
527
+ Available CSS variables (already in the page):
528
+ --primary, --secondary, --accent
529
+ --bg, --bg-card, --bg-elevated
530
+ --text, --text-light, --text-muted
531
+ --border, --card-radius, --card-shadow
532
+
533
+ Available CSS classes:
534
+ .section-title h2 section header with decorative line
535
+ .about-card padded content card
536
+ .contact-section list container
537
+ .contact-item row with icon + text
538
+ .pill small rounded badge/tag
539
+ .spacer-lg bottom spacer
540
+
541
+ RULES:
542
+ - Output ONLY the HTML. No explanation, no markdown fences, no ```html.
543
+ - Mobile-friendly layout (max ~480px wide).
544
+ - Use CSS variables for all colors so it works with light and dark themes.
545
+ - Do NOT output <html>, <head>, <body>, or <style> tags."""
546
+
547
+ def generate_custom_block(description, chat_history):
548
+ chat_history = chat_history or []
549
+ if not current_html:
550
+ chat_history.append({"role": "assistant", "content": "⚠️ Generate a website first."})
551
+ return chat_history, preview(current_html), gr.update()
552
+
553
+ chat_history.append({"role": "user", "content": f"[Custom Block] {description}"})
554
+
555
+ raw = ollama_chat(QWEN_CODER_MODEL, CUSTOM_BLOCK_SYSTEM,
556
+ f"Generate an HTML block for: {description}", temperature=0.4)
557
+
558
+ if raw.startswith("ERROR:"):
559
+ chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
560
+ return chat_history, preview(current_html), gr.update()
561
+
562
+ new_html = raw.strip()
563
+ new_html = re.sub(r'^```html?\s*\n?', '', new_html)
564
+ new_html = re.sub(r'\n?```\s*$', '', new_html)
565
+
566
+ if '<!DOCTYPE' in new_html or '<html' in new_html.lower():
567
+ chat_history.append({"role": "assistant", "content": "❌ Model returned full page. Try a simpler description."})
568
+ return chat_history, preview(current_html), gr.update()
569
+
570
+ new_block = {"id": _uid(), "type": "Custom", "html": new_html}
571
+ page_blocks.append(new_block)
572
+ rebuild_html()
573
+ choices = get_block_choices()
574
+ new_label = _block_label(new_block)
575
+ chat_history.append({"role": "assistant", "content": "✅ Custom block added! Select it to edit further."})
576
+ return chat_history, preview(current_html), gr.update(choices=choices, value=new_label)
577
+
578
+ # ============================================================
579
+ # STYLE EDITOR (CSS variable injection, no block needed)
580
+ # ============================================================
581
+ PARSE_SYSTEM_STYLE = """Parse the user's instruction about visual style into JSON.
582
+
583
+ Output format: {"actions": [{"prop": "...", "value": "..."}]}
584
+
585
+ Available props:
586
+ - primary, secondary, accent, bg, text (CSS color values like #006400)
587
+ - font_heading, font_body (CSS font-family strings)
588
+ - card_radius (e.g. "20px")
589
+
590
+ Examples:
591
+ - "Change primary color to forest green" → {"actions":[{"prop":"primary","value":"#228B22"}]}
592
+ - "Dark theme, black background" → {"actions":[{"prop":"bg","value":"#1a1a1a"},{"prop":"text","value":"#f0f0f0"}]}
593
+ - "Use Georgia for headings" → {"actions":[{"prop":"font_heading","value":"Georgia, serif"}]}
594
+
595
+ Output ONLY JSON."""
596
+
597
+ def handle_style_edit(instruction, chat_history):
598
+ chat_history = chat_history or []
599
+ chat_history.append({"role": "user", "content": f"[Style] {instruction}"})
600
+
601
+ raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_STYLE, instruction)
602
+ if raw.startswith("ERROR:"):
603
+ chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
604
+ return chat_history, preview(current_html)
605
+
606
+ parsed = parse_json_from_response(raw)
607
+ actions = parsed.get("actions", [])
608
+ if not actions:
609
+ chat_history.append({"role": "assistant", "content": f"❌ Could not parse. Raw: {raw[:150]}"})
610
+ return chat_history, preview(current_html)
611
+
612
+ css_var_map = {
613
+ "primary": "--primary", "secondary": "--secondary",
614
+ "accent": "--accent", "bg": "--bg", "text": "--text",
615
+ "card_radius": "--card-radius",
616
+ }
617
+ lines, messages = [], []
618
+ for a in actions:
619
+ prop, value = a.get("prop", ""), a.get("value", "")
620
+ if prop == "font_heading":
621
+ lines.append(f".hero h1, .section-title h2, .menu-header h1, .menu-category-title {{ font-family: {value} !important; }}")
622
+ messages.append(f"Changed font_heading")
623
+ elif prop == "font_body":
624
+ lines.append(f"body {{ font-family: {value} !important; }}")
625
+ messages.append(f"Changed font_body")
626
+ elif prop in css_var_map:
627
+ lines.append(f":root {{ {css_var_map[prop]}: {value}; }}")
628
+ messages.append(f"Changed {prop} → {value}")
629
+ else:
630
+ messages.append(f"Unknown prop: {prop}")
631
+
632
+ if lines:
633
+ current_site_info["custom_css"] = current_site_info.get("custom_css", "") + "\n" + "\n".join(lines)
634
+ rebuild_html()
635
+ chat_history.append({"role": "assistant", "content": "🎨 " + " · ".join(messages) + "\n\n✅ Done!"})
636
+ return chat_history, preview(current_html)
637
+
638
+ # ============================================================
639
+ # CREATE WEBSITE
640
+ # ============================================================
641
+ def _make_hero_html(shop_name, desc, hours, addr):
642
+ location_short = addr.split(",")[0][:25] if "," in addr else addr[:25]
643
+ return f"""\
644
+ <div class="hero">
645
+ <div class="hero-badge">
646
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
647
+ Est. 2024
648
+ </div>
649
+ <h1>{shop_name}</h1>
650
+ <p class="hero-tagline">{desc[:80]}</p>
651
+ </div>
652
+ <div class="info-pills">
653
+ <div class="pill">
654
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
655
+ <span>{hours or 'Open Daily'}</span>
656
+ </div>
657
+ <div class="pill">
658
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
659
+ <span>{location_short or 'Visit Us'}</span>
660
+ </div>
661
+ </div>"""
662
+
663
+ def _make_about_html(desc):
664
+ return f"""\
665
+ <div class="section-title">
666
+ <h2>Our Story</h2>
667
+ <div class="line"></div>
668
+ </div>
669
+ <div class="about-card">
670
+ <p>{desc}</p>
671
+ </div>"""
672
+
673
+ def _make_contact_html(phone, addr, hours):
674
+ addr_enc = urllib.parse.quote_plus(addr)
675
+ return f"""\
676
+ <div class="section-title">
677
+ <h2>Find Us</h2>
678
+ <div class="line"></div>
679
+ </div>
680
+ <div class="contact-section">
681
+ <a class="contact-item" href="tel:{phone}">
682
+ <div class="contact-icon">
683
+ <svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
684
+ </div>
685
+ <div class="contact-text"><div class="label">Phone</div><div class="value">{phone or '—'}</div></div>
686
+ </a>
687
+ <a class="contact-item" href="https://maps.google.com/?q={addr_enc}" target="_blank">
688
+ <div class="contact-icon">
689
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
690
+ </div>
691
+ <div class="contact-text"><div class="label">Address</div><div class="value">{addr or '—'}</div></div>
692
+ </a>
693
+ <div class="contact-item">
694
+ <div class="contact-icon">
695
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
696
+ </div>
697
+ <div class="contact-text"><div class="label">Hours</div><div class="value">{hours or '—'}</div></div>
698
+ </div>
699
+ </div>
700
+ <div class="spacer-lg"></div>"""
701
+
702
+ def create_website(shop_name, desc, phone, addr, hours, style, menu_zip, progress=gr.Progress()):
703
+ global page_blocks, current_menu_data, current_site_info, current_template_key, current_menu_item_template
704
+ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT # reset on each new site
705
+
706
+ progress(0.1, desc="Processing menu...")
707
+ current_menu_data = process_menu_zip(menu_zip)
708
+ current_site_info = {
709
+ "shop_name": shop_name or "My Shop",
710
+ "description": desc or "Welcome to our shop!",
711
+ "phone": phone or "", "address": addr or "", "hours": hours or "",
712
+ "custom_css": "", "posters": [], "poster_base64": "",
713
+ }
714
+ current_template_key = {"Warm & Cozy": "warm", "Dark & Elegant": "dark"}.get(style, "warm")
715
+
716
+ progress(0.4, desc="Building blocks...")
717
+ page_blocks = [
718
+ {"id": _uid(), "type": "Hero Banner", "html": _make_hero_html(shop_name or "My Shop", desc or "Welcome!", hours or "", addr or "")},
719
+ {"id": _uid(), "type": "Promo / Event", "html": BLOCK_DEFAULTS["Promo / Event"]},
720
+ {"id": _uid(), "type": "About / Story", "html": _make_about_html(desc or "Welcome to our shop!")},
721
+ {"id": _uid(), "type": "Contact Info", "html": _make_contact_html(phone or "", addr or "", hours or "")},
722
+ ]
723
+
724
+ progress(0.8, desc="Rendering...")
725
+ rebuild_html()
726
+
727
+ n_items = sum(len(v) for v in current_menu_data.values())
728
+ n_cats = len(current_menu_data)
729
+ progress(1.0)
730
+ print(f"[create_website] page_blocks={len(page_blocks)}, html_len={len(current_html)}")
731
+ return preview(current_html), f"✅ {n_items} items · {n_cats} categories"
732
+
733
+ # ============================================================
734
+ # MENU ITEM DATA EDITOR (add / remove / change price)
735
+ # ============================================================
736
+ PARSE_SYSTEM_MENU = """Parse the user's instruction about menu items into JSON.
737
+
738
+ Output format:
739
+ {"actions": [{"op": "add|remove|change_price", "name": "...", "price": 0.0, "category": "..."}]}
740
+
741
+ Examples:
742
+ - "Add Iced Mocha $6 in Coffee" → {"actions":[{"op":"add","name":"Iced Mocha","price":6.00,"category":"Coffee"}]}
743
+ - "Remove Espresso" → {"actions":[{"op":"remove","name":"Espresso"}]}
744
+ - "Change Latte price to $5.50" → {"actions":[{"op":"change_price","name":"Latte","price":5.50}]}
745
+
746
+ Output ONLY JSON."""
747
+
748
+ def handle_menu_data_edit(instruction, uploaded_image, chat_history):
749
+ chat_history = chat_history or []
750
+ if not current_html:
751
+ chat_history.append({"role": "assistant", "content": "⚠️ Generate a website first."})
752
+ return chat_history, preview(current_html)
753
+
754
+ chat_history.append({"role": "user", "content": f"[Menu Items] {instruction}"})
755
+
756
+ raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_MENU, instruction)
757
+ if raw.startswith("ERROR:"):
758
+ chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
759
+ return chat_history, preview(current_html)
760
+
761
+ parsed = parse_json_from_response(raw)
762
+ actions = parsed.get("actions", [])
763
+ if not actions:
764
+ chat_history.append({"role": "assistant", "content": f"❌ Could not parse. Raw: {raw[:150]}"})
765
+ return chat_history, preview(current_html)
766
+
767
+ # Handle optional uploaded image
768
+ img_b64 = None
769
+ if uploaded_image:
770
+ try:
771
+ with open(uploaded_image, 'rb') as f:
772
+ img_b64 = f"data:image/png;base64,{base64.b64encode(f.read()).decode()}"
773
+ except Exception:
774
+ pass
775
+
776
+ messages = []
777
+ for a in actions:
778
+ op = a.get("op", "")
779
+ name = a.get("name", "")
780
+
781
+ if op == "add":
782
+ cat = a.get("category", "Menu")
783
+ price = a.get("price", 0.0)
784
+ if cat not in current_menu_data:
785
+ current_menu_data[cat] = []
786
+ current_menu_data[cat].append({"name": name, "price": price, "image_base64": img_b64 or ""})
787
+ messages.append(f"Added {name} (${price:.2f}) to {cat}")
788
+
789
+ elif op == "remove":
790
+ found = False
791
+ for cat, items in current_menu_data.items():
792
+ for i, item in enumerate(items):
793
+ if item["name"].lower() == name.lower():
794
+ items.pop(i); messages.append(f"Removed {name}"); found = True; break
795
+ if found: break
796
+ if not found:
797
+ messages.append(f"'{name}' not found")
798
+
799
+ elif op == "change_price":
800
+ price = a.get("price", 0.0)
801
+ found = False
802
+ for cat, items in current_menu_data.items():
803
+ for item in items:
804
+ if item["name"].lower() == name.lower():
805
+ item["price"] = price; messages.append(f"{name} → ${price:.2f}"); found = True; break
806
+ if found: break
807
+ if not found:
808
+ messages.append(f"'{name}' not found")
809
+
810
+ rebuild_html()
811
+ extra = " (with photo)" if img_b64 else ""
812
+ chat_history.append({"role": "assistant", "content": "🔧 " + " · ".join(messages) + extra + "\n\n✅ Done!"})
813
+ return chat_history, preview(current_html)
814
+
815
+ # ============================================================
816
+ # MENU STRUCTURE EDITOR
817
+ # ============================================================
818
+ MENU_STRUCTURE_SYSTEM = """You are editing the HTML template for a single menu item card on a mobile website.
819
+
820
+ Available placeholders:
821
+ {name} — item name text [REQUIRED — must keep]
822
+ {price} — item price text e.g. "$4.50" [REQUIRED — must keep]
823
+ {img_tag} — full <img> element with class="menu-item-img" (76×76 px square)
824
+ {img_src} — raw image URL/base64 string — MUST be used as: <img src="{img_src}" ...> NEVER put {img_src} as text content inside a div or span
825
+ {description} — per-item text description string (may be empty) [use ONLY when adding a prose text description field]
826
+
827
+ IMAGE RULES (CRITICAL):
828
+ - {img_src} is a raw URL string. You MUST embed it like this: <img src="{img_src}" style="..." alt="{name}">
829
+ - NEVER write just {img_src} alone as element content — it will render as raw text on the page
830
+ - When changing image layout/size, use {img_src} with a custom <img> tag and omit {img_tag}
831
+
832
+ MULTI-COLUMN LAYOUT RULES:
833
+ - To make items display in 2 columns, add a <style> tag at the TOP of the template:
834
+ <style>.menu-category { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .menu-category-title { grid-column:1/-1; }</style>
835
+ - Then make the item itself a compact vertical block (display:block, not flex row)
836
+
837
+ GENERAL RULES:
838
+ - Output ONLY the modified HTML template. No explanation, no markdown fences, no \`\`\`html.
839
+ - Keep {name}, {price}, and at least one of {img_tag} or {img_src}
840
+ - For UI decorations (stars, spice icons, badges): write them as static HTML/emoji, NOT as a placeholder
841
+ - Use CSS variables for colors (--primary, --bg-card, --text, --text-muted, --border)
842
+ - Do NOT add <html>, <head>, or <body> tags. A single <style> tag at the top is allowed for layout.
843
+ - Use inline styles freely to override any layout constraints"""
844
+
845
+ def edit_menu_structure(instruction, chat_history):
846
+ global current_menu_item_template
847
+ chat_history = chat_history or []
848
+
849
+ if not current_html:
850
+ chat_history.append({"role": "assistant", "content": "⚠️ Generate a website first."})
851
+ return chat_history, preview(current_html)
852
+
853
+ chat_history.append({"role": "user", "content": f"[Menu Structure] {instruction}"})
854
+
855
+ prompt = f"""Current menu item template:
856
+ {current_menu_item_template}
857
+
858
+ Instruction: {instruction}
859
+
860
+ Output the modified template (keep {{name}}, {{price}}, {{img_tag}} placeholders):"""
861
+
862
+ raw = ollama_chat(QWEN_CODER_MODEL, MENU_STRUCTURE_SYSTEM, prompt, temperature=0.3)
863
+
864
+ if raw.startswith("ERROR:"):
865
+ chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
866
+ return chat_history, preview(current_html)
867
+
868
+ new_template = raw.strip()
869
+ new_template = re.sub(r'^```html?\s*\n?', '', new_template)
870
+ new_template = re.sub(r'\n?```\s*$', '', new_template)
871
+
872
+ # Auto-fix: {img_src} as bare text content (not inside any attribute or CSS url())
873
+ # Valid uses: src="{img_src}", url({img_src}), url('{img_src}'), url("{img_src}")
874
+ # Invalid (shows raw base64 on page): <div>{img_src}</div>
875
+ if "{img_src}" in new_template and not re.search(
876
+ r'(?:src\s*=\s*["\']?|url\s*\(\s*["\']?)\{img_src\}', new_template
877
+ ):
878
+ new_template = new_template.replace(
879
+ "{img_src}",
880
+ '<img src="{img_src}" style="width:100%;height:160px;object-fit:cover;border-radius:8px;" alt="{name}">'
881
+ )
882
+
883
+ # Validate required placeholders — {img_tag} can be replaced by {img_src}
884
+ missing = [p for p in ["{name}", "{price}"] if p not in new_template]
885
+ if "{img_tag}" not in new_template and "{img_src}" not in new_template:
886
+ missing.append("{img_tag} or {img_src}")
887
+ if missing:
888
+ chat_history.append({"role": "assistant", "content": f"❌ Model dropped placeholders: {missing}. Try again."})
889
+ return chat_history, preview_menu(current_html)
890
+
891
+ current_menu_item_template = new_template
892
+
893
+ # If {description} was added and items don't have descriptions yet, auto-generate them
894
+ if "{description}" in new_template:
895
+ items_needing_desc = [
896
+ item for items in current_menu_data.values() for item in items
897
+ if not item.get("description")
898
+ ]
899
+ if items_needing_desc:
900
+ chat_history.append({"role": "assistant", "content": "⏳ Generating item descriptions..."})
901
+ all_names = [item["name"] for item in items_needing_desc]
902
+ prompt = f"Generate descriptions for these menu items: {json.dumps(all_names)}"
903
+ raw2 = ollama_chat(QWEN_MODEL, DESCRIBE_SYSTEM, prompt, temperature=0.7)
904
+ parsed2 = parse_json_from_response(raw2)
905
+ desc_map = {d["name"]: d["description"] for d in parsed2.get("items", []) if "name" in d and "description" in d}
906
+ for items in current_menu_data.values():
907
+ for item in items:
908
+ if item["name"] in desc_map:
909
+ item["description"] = desc_map[item["name"]]
910
+ count = len(desc_map)
911
+ chat_history.append({"role": "assistant", "content": f"✅ Structure updated + {count} descriptions generated!"})
912
+ else:
913
+ chat_history.append({"role": "assistant", "content": "✅ Menu item structure updated!"})
914
+ else:
915
+ chat_history.append({"role": "assistant", "content": "✅ Menu item structure updated!"})
916
+
917
+ rebuild_html()
918
+ return chat_history, preview_menu(current_html)
919
+
920
+ DESCRIBE_SYSTEM = """Generate short, appetising descriptions for menu items.
921
+ Given a list of item names, output ONLY JSON:
922
+ {"items": [{"name": "...", "description": "..."}]}
923
+ Each description: 1 sentence, ≤12 words, mouth-watering and relevant to the item.
924
+ Output ONLY JSON."""
925
+
926
+
927
+ def reset_menu_structure(chat_history):
928
+ global current_menu_item_template
929
+ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT
930
+ rebuild_html()
931
+ chat_history = (chat_history or []) + [{"role": "assistant", "content": "↩️ Menu structure reset to default."}]
932
+ return chat_history, preview_menu(current_html)
933
+
934
+ # ============================================================
935
+ # POSTER
936
+ # ============================================================
937
+ def load_sd_model():
938
+ global sd_pipe
939
+ if sd_pipe is not None:
940
+ return sd_pipe
941
+ if not SD_AVAILABLE:
942
+ return None
943
+ sd_pipe = AutoPipelineForText2Image.from_pretrained(
944
+ SD_MODEL_ID,
945
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
946
+ variant="fp16" if torch.cuda.is_available() else None,
947
+ )
948
+ if torch.cuda.is_available():
949
+ sd_pipe = sd_pipe.to("cuda")
950
+ sd_pipe.enable_attention_slicing()
951
+ return sd_pipe
952
+
953
+ PARSE_SYSTEM_POSTER = """Extract key promotional text from the user's description for a poster.
954
+ Output ONLY JSON: {"title": "...", "offer": "...", "detail": "...", "tagline": "..."}
955
+ - title: main headline (2-5 words)
956
+ - offer: the key offer (e.g. "20% OFF")
957
+ - detail: conditions or date (e.g. "Valid Mon-Fri")
958
+ - tagline: short catchy phrase
959
+ Output ONLY JSON."""
960
+
961
+ def extract_poster_info(desc):
962
+ raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_POSTER, desc, temperature=0.4)
963
+ if raw.startswith("ERROR:"):
964
+ return {"title": "Special Offer", "offer": "", "detail": "", "tagline": ""}
965
+ parsed = parse_json_from_response(raw)
966
+ return parsed if parsed else {"title": "Special Offer", "offer": "", "detail": "", "tagline": ""}
967
+
968
+ def gen_sd_prompt(desc, bg_style):
969
+ if bg_style == "Restaurant atmosphere":
970
+ sys = "Generate a Stable Diffusion prompt for a restaurant/cafe poster background. Show warm interior, food, bokeh. Under 40 words, no quality tags."
971
+ fallback = "warm cozy restaurant interior, wooden table, bokeh lights, appetizing food, soft warm lighting"
972
+ suffix = "no text, no words, no people, professional food photography, 4k, bokeh"
973
+ else:
974
+ sys = "Generate a Stable Diffusion prompt that visually matches the promotion. Style: vibrant, professional. Under 40 words, no quality tags."
975
+ fallback = "happy people enjoying food, warm atmosphere, vibrant colors"
976
+ suffix = "no text, no words, high quality, 4k, professional photography"
977
+ raw = ollama_chat(QWEN_MODEL, sys, desc, temperature=0.5)
978
+ if raw.startswith("ERROR:"):
979
+ raw = fallback
980
+ return raw.strip().strip('"\'') + ", " + suffix
981
+
982
+ def compose_poster(bg_img, poster_info):
983
+ img = bg_img.copy().convert("RGBA")
984
+ w, h = img.size
985
+ overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
986
+ draw_ov = ImageDraw.Draw(overlay)
987
+ for i in range(h // 3, h):
988
+ alpha = int(210 * (i - h // 3) / (h * 2 // 3))
989
+ draw_ov.line([(0, i), (w, i)], fill=(0, 0, 0, min(alpha, 210)))
990
+ img = Image.alpha_composite(img, overlay)
991
+ draw = ImageDraw.Draw(img)
992
+
993
+ def get_font(size):
994
+ for fp in ["/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Arial.ttf",
995
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
996
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"]:
997
+ try:
998
+ return ImageFont.truetype(fp, size=size)
999
+ except Exception:
1000
+ continue
1001
+ return ImageFont.load_default()
1002
+
1003
+ def fit_font(text, base_size, min_size=12):
1004
+ max_w = int(w * 0.88)
1005
+ size = base_size
1006
+ while size >= min_size:
1007
+ font = get_font(size)
1008
+ bbox = draw.textbbox((0, 0), text, font=font)
1009
+ if bbox[2] - bbox[0] <= max_w:
1010
+ return font
1011
+ size = max(min_size, int(size * 0.82))
1012
+ return get_font(min_size)
1013
+
1014
+ def wrap_text(text, font, max_w):
1015
+ words, lines, cur = text.split(), [], []
1016
+ for word in words:
1017
+ test = " ".join(cur + [word])
1018
+ if draw.textbbox((0, 0), test, font=font)[2] <= max_w:
1019
+ cur.append(word)
1020
+ else:
1021
+ if cur: lines.append(" ".join(cur))
1022
+ cur = [word]
1023
+ if cur: lines.append(" ".join(cur))
1024
+ return lines or [text]
1025
+
1026
+ def draw_centered(text, font, y, color):
1027
+ if not text: return 0
1028
+ lines = wrap_text(text, font, int(w * 0.88))
1029
+ line_h = draw.textbbox((0, 0), "A", font=font)[3] + 4
1030
+ for i, line in enumerate(lines):
1031
+ tw = draw.textbbox((0, 0), line, font=font)[2]
1032
+ draw.text(((w - tw) // 2, y + i * line_h), line, font=font, fill=color)
1033
+ return line_h * len(lines)
1034
+
1035
+ y = h - int(h * 0.06)
1036
+ if poster_info.get("detail"):
1037
+ f = fit_font(poster_info["detail"], int(h * 0.040))
1038
+ draw_centered(poster_info["detail"], f, y - int(h * 0.045), (200, 200, 200, 255))
1039
+ y -= int(h * 0.07)
1040
+ if poster_info.get("offer"):
1041
+ f = fit_font(poster_info["offer"], int(h * 0.11))
1042
+ draw_centered(poster_info["offer"], f, y - int(h * 0.11), (255, 215, 50, 255))
1043
+ y -= int(h * 0.13)
1044
+ if poster_info.get("title"):
1045
+ f = fit_font(poster_info["title"], int(h * 0.065))
1046
+ draw_centered(poster_info["title"], f, y - int(h * 0.07), (255, 255, 255, 255))
1047
+ y -= int(h * 0.09)
1048
+ if poster_info.get("tagline"):
1049
+ f = fit_font(poster_info["tagline"], int(h * 0.038))
1050
+ draw_centered(poster_info["tagline"], f, y - int(h * 0.04), (160, 230, 160, 255))
1051
+ return img.convert("RGB")
1052
+
1053
+ def _poster_status():
1054
+ n = len(current_site_info.get("posters", []))
1055
+ return f"🖼️ {n} poster{'s' if n != 1 else ''} in carousel" if n else "*No posters added yet*"
1056
+
1057
+ def poster_flow(desc, bg_prompt, uploaded_bg, add_to_site, bg_style="Restaurant atmosphere"):
1058
+ poster_info = extract_poster_info(desc)
1059
+ info = f"📝 **Extracted:** {poster_info.get('title','')} · {poster_info.get('offer','')} · {poster_info.get('detail','')}"
1060
+
1061
+ if uploaded_bg is not None:
1062
+ try:
1063
+ bg_img = Image.open(uploaded_bg).convert("RGB").resize((512, 768), Image.LANCZOS)
1064
+ info += "\n\n🖼️ Using uploaded background"
1065
+ except Exception as e:
1066
+ return None, f"❌ Failed to load image: {e}", "", _poster_status()
1067
+ else:
1068
+ if not SD_AVAILABLE:
1069
+ return None, "❌ SD not available. Upload a background image.", preview(current_html) if current_html else "", _poster_status()
1070
+ sd_prompt = bg_prompt.strip() if bg_prompt and bg_prompt.strip() else gen_sd_prompt(desc, bg_style)
1071
+ pipe = load_sd_model()
1072
+ if not pipe:
1073
+ return None, "❌ Failed to load SD model.", "", _poster_status()
1074
+ with torch.no_grad():
1075
+ bg_img = pipe(prompt=sd_prompt, num_inference_steps=4, guidance_scale=0.0, width=512, height=768).images[0]
1076
+ info += f"\n\n🎨 SD prompt: {sd_prompt}"
1077
+
1078
+ final_img = compose_poster(bg_img, poster_info)
1079
+ final_img.save(WORK_DIR / "latest_poster.png")
1080
+
1081
+ if add_to_site and current_html:
1082
+ buf = io.BytesIO()
1083
+ final_img.save(buf, format='PNG')
1084
+ new_poster = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
1085
+ if "posters" not in current_site_info:
1086
+ current_site_info["posters"] = []
1087
+ current_site_info["posters"].append(new_poster)
1088
+ rebuild_html()
1089
+ n = len(current_site_info["posters"])
1090
+ info += f"\n\n✅ Added! {n} poster{'s' if n > 1 else ''} in carousel."
1091
+
1092
+ return final_img, info, preview(current_html) if current_html else "", _poster_status()
1093
+
1094
+ def remove_last_poster():
1095
+ posters = current_site_info.get("posters", [])
1096
+ if not posters:
1097
+ return "*No posters to remove*", preview(current_html)
1098
+ posters.pop()
1099
+ current_site_info["posters"] = posters
1100
+ rebuild_html()
1101
+ return _poster_status(), preview(current_html)
1102
+
1103
+ def clear_all_posters():
1104
+ current_site_info["posters"] = []
1105
+ current_site_info["poster_base64"] = ""
1106
+ rebuild_html()
1107
+ return "*No posters added yet*", preview(current_html)
1108
+
1109
+ # ============================================================
1110
+ # HELPERS
1111
+ # ============================================================
1112
+ def esc(html):
1113
+ return html.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
1114
+
1115
+ def preview(html):
1116
+ if not html:
1117
+ return '<div style="padding:40px;text-align:center;color:#999;">No website yet. Create one first.</div>'
1118
+ return f'<div style="max-width:480px;margin:0 auto;height:700px;overflow-y:auto;border:1px solid #ddd;border-radius:12px;"><iframe srcdoc="{esc(html)}" style="width:100%;height:100%;border:none;"></iframe></div>'
1119
+
1120
+ def preview_menu(html):
1121
+ """Render preview with the Menu page active instead of Home."""
1122
+ if not html:
1123
+ return preview(html)
1124
+ html_m = html.replace(
1125
+ '<div class="page active" id="page-home">',
1126
+ '<div class="page" id="page-home">'
1127
+ ).replace(
1128
+ '<div class="page" id="page-menu">',
1129
+ '<div class="page active" id="page-menu">'
1130
+ )
1131
+ return preview(html_m)
1132
+
1133
+ def download():
1134
+ if not current_html:
1135
+ return None
1136
+ p = WORK_DIR / "my_website.html"
1137
+ p.write_text(current_html, encoding='utf-8')
1138
+ return str(p)
1139
+
1140
+ # ============================================================
1141
+ # UI
1142
+ # ============================================================
1143
+ def build_app():
1144
+ with gr.Blocks(title="ShopSite AI") as app:
1145
+ gr.Markdown("# 🏪 ShopSite AI\n*AI-powered mobile website generator for small businesses*")
1146
+
1147
+ # ---- Tab 1: Create ----
1148
+ with gr.Tab("🏗️ Create Website"):
1149
+ with gr.Row():
1150
+ with gr.Column(scale=1):
1151
+ gr.Markdown("### Business Info")
1152
+ shop_name = gr.Textbox(label="Shop Name", placeholder="The Cozy Bean")
1153
+ desc = gr.Textbox(label="Description", placeholder="A neighborhood coffee shop...", lines=3)
1154
+ phone = gr.Textbox(label="Phone", placeholder="+44 20 7946 0958")
1155
+ addr = gr.Textbox(label="Address", placeholder="42 High Street, London")
1156
+ hours = gr.Textbox(label="Hours", placeholder="Mon-Fri 7am-7pm")
1157
+ style = gr.Dropdown(label="Template", choices=["Warm & Cozy", "Dark & Elegant"], value="Warm & Cozy")
1158
+ gr.Markdown("### Menu ZIP\n`Category/Name_Price.png`")
1159
+ menu_zip = gr.File(label="Upload ZIP", file_types=[".zip"])
1160
+ create_btn = gr.Button("🚀 Generate", variant="primary", size="lg")
1161
+ status = gr.Markdown("")
1162
+ with gr.Column(scale=1):
1163
+ gr.Markdown("### 📱 Preview")
1164
+ create_prev = gr.HTML()
1165
+
1166
+ # ---- Tab 2: Edit ----
1167
+ with gr.Tab("📝 Edit Website"):
1168
+ with gr.Row():
1169
+ with gr.Column(scale=1):
1170
+
1171
+ gr.Markdown("### Page Blocks")
1172
+ block_radio = gr.Radio(label="Select a block to edit", choices=[], interactive=True, value=None)
1173
+
1174
+ with gr.Row():
1175
+ move_up_btn = gr.Button("⬆️ Up", size="sm")
1176
+ move_dn_btn = gr.Button("⬇️ Down", size="sm")
1177
+ remove_btn = gr.Button("🗑️ Remove", size="sm")
1178
+
1179
+ with gr.Row():
1180
+ add_type = gr.Dropdown(
1181
+ choices=list(BLOCK_DEFAULTS.keys()),
1182
+ value="Hero Banner", label="New block type", scale=2
1183
+ )
1184
+ add_btn = gr.Button("➕ Add", size="sm", scale=1)
1185
+
1186
+ gr.Markdown("---")
1187
+ gr.Markdown("### ✨ Generate Custom Block")
1188
+ gr.Markdown("*Describe a new block — LLM generates the HTML from scratch*")
1189
+ with gr.Row():
1190
+ custom_block_msg = gr.Textbox(placeholder='e.g. "A loyalty card section with 10 stamp slots" · "Opening hours table"', lines=2, scale=3, label="")
1191
+ custom_block_btn = gr.Button("Generate", variant="primary", scale=1)
1192
+
1193
+ gr.Markdown("---")
1194
+ gr.Markdown("### Edit Selected Block")
1195
+ gr.Markdown("*Describe what to change — the LLM rewrites the block HTML*")
1196
+ chatbot = gr.Chatbot(height=280, type="messages")
1197
+ with gr.Row():
1198
+ edit_msg = gr.Textbox(placeholder='e.g. "Add a description field to each menu item"', lines=2, scale=3, label="")
1199
+ edit_send = gr.Button("Send", variant="primary", scale=1)
1200
+
1201
+ gr.Markdown("### Style / Colors")
1202
+ with gr.Row():
1203
+ style_msg = gr.Textbox(placeholder='e.g. "Make primary color forest green"', scale=3, label="")
1204
+ style_send = gr.Button("Apply", scale=1)
1205
+
1206
+ gr.Markdown("### Menu Items")
1207
+ gr.Markdown("*Add, remove, or change prices of individual items*")
1208
+ with gr.Row():
1209
+ menu_data_msg = gr.Textbox(placeholder='e.g. "Add Iced Mocha $6 in Coffee" · "Remove Espresso" · "Change Latte to $5.50"', lines=2, scale=3, label="")
1210
+ menu_data_image = gr.Image(label="📷 Photo", type="filepath", scale=1, height=80)
1211
+ menu_data_send = gr.Button("Send", variant="primary", scale=1)
1212
+
1213
+ gr.Markdown("### Menu Item Structure")
1214
+ gr.Markdown("*Rewrite the layout of every menu card — add descriptions, ratings, badges, etc.*")
1215
+ with gr.Row():
1216
+ menu_struct_msg = gr.Textbox(placeholder='e.g. "Add a short description field below the item name"', lines=2, scale=3, label="")
1217
+ menu_struct_send = gr.Button("Send", variant="primary", scale=1)
1218
+ menu_reset_btn = gr.Button("↩️ Reset menu to default", size="sm")
1219
+
1220
+ with gr.Column(scale=1):
1221
+ gr.Markdown("### 📱 Live Preview")
1222
+ edit_prev = gr.HTML()
1223
+
1224
+ # Custom block generation
1225
+ custom_block_btn.click(
1226
+ generate_custom_block, [custom_block_msg, chatbot], [chatbot, edit_prev, block_radio]
1227
+ ).then(lambda: "", outputs=custom_block_msg)
1228
+ custom_block_msg.submit(
1229
+ generate_custom_block, [custom_block_msg, chatbot], [chatbot, edit_prev, block_radio]
1230
+ ).then(lambda: "", outputs=custom_block_msg)
1231
+
1232
+ # Wire up block management
1233
+ add_btn.click(handle_add_block, [add_type, block_radio], [edit_prev, block_radio])
1234
+ remove_btn.click(handle_remove_block, block_radio, [edit_prev, block_radio])
1235
+ move_up_btn.click(handle_move_up, block_radio, [edit_prev, block_radio])
1236
+ move_dn_btn.click(handle_move_down, block_radio, [edit_prev, block_radio])
1237
+
1238
+ # Wire up LLM editing
1239
+ edit_send.click(
1240
+ edit_block_with_llm, [block_radio, edit_msg, chatbot], [chatbot, edit_prev]
1241
+ ).then(lambda: "", outputs=edit_msg)
1242
+ edit_msg.submit(
1243
+ edit_block_with_llm, [block_radio, edit_msg, chatbot], [chatbot, edit_prev]
1244
+ ).then(lambda: "", outputs=edit_msg)
1245
+
1246
+ # Style
1247
+ style_send.click(
1248
+ handle_style_edit, [style_msg, chatbot], [chatbot, edit_prev]
1249
+ ).then(lambda: "", outputs=style_msg)
1250
+
1251
+ # Menu item data editing
1252
+ menu_data_send.click(
1253
+ handle_menu_data_edit, [menu_data_msg, menu_data_image, chatbot], [chatbot, edit_prev]
1254
+ ).then(lambda: ("", None), outputs=[menu_data_msg, menu_data_image])
1255
+ menu_data_msg.submit(
1256
+ handle_menu_data_edit, [menu_data_msg, menu_data_image, chatbot], [chatbot, edit_prev]
1257
+ ).then(lambda: ("", None), outputs=[menu_data_msg, menu_data_image])
1258
+
1259
+ # Menu structure editing
1260
+ menu_struct_send.click(
1261
+ edit_menu_structure, [menu_struct_msg, chatbot], [chatbot, edit_prev]
1262
+ ).then(lambda: "", outputs=menu_struct_msg)
1263
+ menu_struct_msg.submit(
1264
+ edit_menu_structure, [menu_struct_msg, chatbot], [chatbot, edit_prev]
1265
+ ).then(lambda: "", outputs=menu_struct_msg)
1266
+ menu_reset_btn.click(reset_menu_structure, chatbot, [chatbot, edit_prev])
1267
+
1268
+ # ---- Tab 3: Poster ----
1269
+ with gr.Tab("🎨 Poster"):
1270
+ with gr.Row():
1271
+ with gr.Column(scale=1):
1272
+ gr.Markdown("### Event / Promotion")
1273
+ edesc = gr.Textbox(label="Promotion description", placeholder="Student discount 20% off, valid Mon-Fri with student ID", lines=2)
1274
+ bg_prompt = gr.Textbox(label="Background prompt (optional)", placeholder="Leave empty to auto-generate", lines=2)
1275
+ bg_style = gr.Radio(choices=["Restaurant atmosphere", "Match the promotion"], value="Restaurant atmosphere", label="Auto-generate style")
1276
+ gr.Markdown("*Or upload your own background photo*")
1277
+ poster_bg = gr.Image(label="Background Image (optional)", type="filepath")
1278
+ add2site = gr.Checkbox(label="Add to website carousel", value=True)
1279
+ pbtn = gr.Button("🎨 Generate Poster", variant="primary", size="lg")
1280
+ pinfo = gr.Markdown("")
1281
+ gr.Markdown("### Manage Carousel")
1282
+ poster_status = gr.Markdown("*No posters added yet*")
1283
+ with gr.Row():
1284
+ remove_poster_btn = gr.Button("🗑️ Remove last", size="sm")
1285
+ clear_poster_btn = gr.Button("🗑️ Clear all", size="sm", variant="stop")
1286
+ with gr.Column(scale=1):
1287
+ gr.Markdown("### Result")
1288
+ pimg = gr.Image(label="Poster", type="pil")
1289
+ gr.Markdown("### 📱 Preview")
1290
+ pprev = gr.HTML()
1291
+
1292
+ pbtn.click(poster_flow, [edesc, bg_prompt, poster_bg, add2site, bg_style], [pimg, pinfo, pprev, poster_status])
1293
+ remove_poster_btn.click(remove_last_poster, outputs=[poster_status, pprev])
1294
+ clear_poster_btn.click(clear_all_posters, outputs=[poster_status, pprev])
1295
+
1296
+ # ---- Tab 4: Download ----
1297
+ with gr.Tab("💾 Download"):
1298
+ gr.Markdown("### Download as single HTML\nFully self-contained with embedded images.")
1299
+ dbtn = gr.Button("💾 Download", variant="primary")
1300
+ dfile = gr.File(label="Website file")
1301
+ dbtn.click(download, outputs=dfile)
1302
+
1303
+ # Create: first update preview+status, then refresh block list
1304
+ create_btn.click(
1305
+ create_website,
1306
+ [shop_name, desc, phone, addr, hours, style, menu_zip],
1307
+ [create_prev, status]
1308
+ ).then(
1309
+ lambda: gr.update(choices=get_block_choices(), value=None),
1310
+ inputs=None,
1311
+ outputs=block_radio
1312
+ ).then(
1313
+ lambda: preview(current_html),
1314
+ inputs=None,
1315
+ outputs=edit_prev
1316
+ )
1317
+
1318
+ return app
1319
+
1320
+ # ============================================================
1321
+ # MAIN
1322
+ # ============================================================
1323
+ if __name__ == "__main__":
1324
+ print("=" * 50)
1325
+ print(" ShopSite AI — Block-based page builder")
1326
+ print("=" * 50)
1327
+
1328
+ if not TEMPLATE_DIR.exists():
1329
+ print(f"\n❌ templates/ not found at {TEMPLATE_DIR.resolve()}")
1330
+ else:
1331
+ print(f"\n✅ Templates: {', '.join(t.stem for t in TEMPLATE_DIR.glob('*.html'))}")
1332
+
1333
+ if TORCH_AVAILABLE:
1334
+ device = "cuda" if torch.cuda.is_available() else "cpu"
1335
+ print(f"✅ torch available. Device: {device}")
1336
+ print(f" General model: {QWEN_MODEL}")
1337
+ print(f" Coder model: {QWEN_CODER_MODEL}")
1338
+ print(" (Models downloaded from HuggingFace on first use)")
1339
+ else:
1340
+ print("❌ torch/transformers not installed. Run: pip install torch transformers accelerate")
1341
+
1342
+ if SD_AVAILABLE:
1343
+ print(f"✅ SD Turbo ready. CUDA: {torch.cuda.is_available()}")
1344
+ else:
1345
+ print("⚠️ Poster generation disabled (diffusers not available).")
1346
+
1347
+ print("\n🚀 http://127.0.0.1:7860\n")
1348
+ in_colab = "google.colab" in str(__import__("sys").modules)
1349
+ if in_colab:
1350
+ build_app().launch(share=True)
1351
+ else:
1352
+ build_app().launch(server_name="127.0.0.1", server_port=7860)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ Pillow
3
+ torch
4
+ transformers>=4.45,<5.0
5
+ accelerate
6
+ diffusers>=0.27
templates/dark.html ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <title>{{SHOP_NAME}}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Raleway:wght@300;400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --primary: #C9A84C;
12
+ --primary-dim: rgba(201,168,76,0.15);
13
+ --secondary: #E8D5A3;
14
+ --accent: #A67C52;
15
+ --bg: #0F0F0F;
16
+ --bg-card: #1A1A1A;
17
+ --bg-elevated: #222222;
18
+ --text: #F0ECE3;
19
+ --text-light: #B8B0A2;
20
+ --text-muted: #6B6560;
21
+ --border: rgba(201,168,76,0.1);
22
+ --card-shadow: 0 4px 24px rgba(0,0,0,0.3);
23
+ --card-radius: 16px;
24
+ --nav-height: 68px;
25
+ }
26
+
27
+ * { margin: 0; padding: 0; box-sizing: border-box; }
28
+
29
+ body {
30
+ font-family: 'Raleway', sans-serif;
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ max-width: 480px;
34
+ margin: 0 auto;
35
+ min-height: 100vh;
36
+ padding-bottom: calc(var(--nav-height) + 16px);
37
+ -webkit-font-smoothing: antialiased;
38
+ overflow-x: hidden;
39
+ }
40
+
41
+ .page { display: none; animation: fadeUp 0.4s ease; }
42
+ .page.active { display: block; }
43
+
44
+ @keyframes fadeUp {
45
+ from { opacity: 0; transform: translateY(12px); }
46
+ to { opacity: 1; transform: translateY(0); }
47
+ }
48
+
49
+ /* Grain texture overlay */
50
+ body::after {
51
+ content: '';
52
+ position: fixed;
53
+ top: 0; left: 0; right: 0; bottom: 0;
54
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
55
+ pointer-events: none;
56
+ z-index: 9999;
57
+ }
58
+
59
+ /* ====== BOTTOM NAV ====== */
60
+ .bottom-nav {
61
+ position: fixed;
62
+ bottom: 0;
63
+ left: 50%;
64
+ transform: translateX(-50%);
65
+ width: 100%;
66
+ max-width: 480px;
67
+ height: var(--nav-height);
68
+ background: rgba(15,15,15,0.92);
69
+ backdrop-filter: blur(16px);
70
+ border-top: 1px solid var(--border);
71
+ display: flex;
72
+ justify-content: space-around;
73
+ align-items: center;
74
+ z-index: 100;
75
+ padding-bottom: env(safe-area-inset-bottom, 0);
76
+ }
77
+
78
+ .nav-item {
79
+ display: flex;
80
+ flex-direction: column;
81
+ align-items: center;
82
+ gap: 3px;
83
+ cursor: pointer;
84
+ padding: 8px 20px;
85
+ border-radius: 14px;
86
+ transition: all 0.25s ease;
87
+ -webkit-tap-highlight-color: transparent;
88
+ user-select: none;
89
+ }
90
+
91
+ .nav-item svg {
92
+ width: 24px; height: 24px;
93
+ stroke: var(--text-muted);
94
+ fill: none; stroke-width: 1.5;
95
+ transition: all 0.25s ease;
96
+ }
97
+ .nav-item span {
98
+ font-size: 10px; font-weight: 600;
99
+ color: var(--text-muted);
100
+ letter-spacing: 0.1em;
101
+ text-transform: uppercase;
102
+ transition: color 0.25s ease;
103
+ }
104
+ .nav-item.active svg { stroke: var(--primary); }
105
+ .nav-item.active span { color: var(--primary); }
106
+
107
+ /* ====== HOME ====== */
108
+ .hero {
109
+ position: relative;
110
+ width: 100%;
111
+ height: 400px;
112
+ background: linear-gradient(175deg, #1a1a1a 0%, #0a0a0a 100%);
113
+ display: flex;
114
+ flex-direction: column;
115
+ justify-content: flex-end;
116
+ padding: 36px 28px;
117
+ overflow: hidden;
118
+ }
119
+
120
+ .hero::before {
121
+ content: '';
122
+ position: absolute;
123
+ top: 0; left: 0; right: 0; bottom: 0;
124
+ background:
125
+ radial-gradient(circle at 30% 20%, rgba(201,168,76,0.06) 0%, transparent 50%),
126
+ radial-gradient(circle at 80% 70%, rgba(166,124,82,0.04) 0%, transparent 40%);
127
+ }
128
+
129
+ .hero-line {
130
+ width: 40px;
131
+ height: 1.5px;
132
+ background: var(--primary);
133
+ margin-bottom: 20px;
134
+ position: relative;
135
+ z-index: 1;
136
+ }
137
+
138
+ .hero h1 {
139
+ font-family: 'Cinzel', serif;
140
+ font-size: 38px;
141
+ font-weight: 700;
142
+ color: var(--text);
143
+ line-height: 1.1;
144
+ letter-spacing: 0.02em;
145
+ margin-bottom: 12px;
146
+ position: relative;
147
+ z-index: 1;
148
+ }
149
+
150
+ .hero-tagline {
151
+ font-size: 14px;
152
+ color: var(--text-light);
153
+ line-height: 1.6;
154
+ font-weight: 300;
155
+ letter-spacing: 0.04em;
156
+ position: relative;
157
+ z-index: 1;
158
+ }
159
+
160
+ .section-title {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 16px;
164
+ padding: 0 28px;
165
+ margin: 36px 0 18px;
166
+ }
167
+ .section-title h2 {
168
+ font-family: 'Cinzel', serif;
169
+ font-size: 20px;
170
+ font-weight: 600;
171
+ color: var(--text);
172
+ letter-spacing: 0.06em;
173
+ }
174
+ .section-title .line {
175
+ flex: 1;
176
+ height: 1px;
177
+ background: linear-gradient(90deg, var(--border), transparent);
178
+ }
179
+
180
+ /* Promo */
181
+ .promo-banner {
182
+ margin: 0 24px;
183
+ border-radius: var(--card-radius);
184
+ overflow: hidden;
185
+ box-shadow: var(--card-shadow);
186
+ border: 1px solid var(--border);
187
+ }
188
+
189
+ .promo-banner img {
190
+ width: 100%;
191
+ height: auto;
192
+ display: block;
193
+ }
194
+
195
+ .promo-banner .promo-placeholder {
196
+ width: 100%;
197
+ height: 200px;
198
+ background: linear-gradient(135deg, #1a1a1a 0%, #2a2520 50%, #1a1510 100%);
199
+ display: flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ flex-direction: column;
203
+ gap: 8px;
204
+ border: 1px solid var(--border);
205
+ }
206
+ .promo-placeholder span {
207
+ color: var(--primary);
208
+ font-family: 'Cinzel', serif;
209
+ font-size: 22px;
210
+ font-weight: 600;
211
+ letter-spacing: 0.04em;
212
+ }
213
+ .promo-placeholder small {
214
+ color: var(--text-muted);
215
+ font-size: 12px;
216
+ letter-spacing: 0.08em;
217
+ text-transform: uppercase;
218
+ }
219
+
220
+ /* About */
221
+ .about-card {
222
+ margin: 0 24px;
223
+ padding: 28px;
224
+ background: var(--bg-card);
225
+ border-radius: var(--card-radius);
226
+ box-shadow: var(--card-shadow);
227
+ border: 1px solid var(--border);
228
+ }
229
+ .about-card p {
230
+ font-size: 14px;
231
+ line-height: 1.8;
232
+ color: var(--text-light);
233
+ font-weight: 300;
234
+ letter-spacing: 0.02em;
235
+ }
236
+
237
+ /* Info pills */
238
+ .info-pills {
239
+ display: flex;
240
+ gap: 10px;
241
+ padding: 0 24px;
242
+ margin: 24px 0;
243
+ flex-wrap: wrap;
244
+ }
245
+ .pill {
246
+ display: inline-flex;
247
+ align-items: center;
248
+ gap: 8px;
249
+ padding: 10px 16px;
250
+ background: var(--bg-card);
251
+ border-radius: 100px;
252
+ font-size: 12px;
253
+ font-weight: 500;
254
+ color: var(--text-light);
255
+ border: 1px solid var(--border);
256
+ letter-spacing: 0.02em;
257
+ white-space: nowrap;
258
+ }
259
+ .pill svg {
260
+ width: 15px; height: 15px;
261
+ stroke: var(--primary);
262
+ fill: none; stroke-width: 1.8;
263
+ flex-shrink: 0;
264
+ }
265
+
266
+ /* Contact */
267
+ .contact-section {
268
+ margin: 36px 24px 16px;
269
+ padding: 24px;
270
+ background: var(--bg-card);
271
+ border-radius: var(--card-radius);
272
+ box-shadow: var(--card-shadow);
273
+ border: 1px solid var(--border);
274
+ }
275
+ .contact-item {
276
+ display: flex;
277
+ align-items: flex-start;
278
+ gap: 14px;
279
+ padding: 14px 0;
280
+ border-bottom: 1px solid var(--border);
281
+ text-decoration: none;
282
+ color: inherit;
283
+ transition: opacity 0.2s;
284
+ }
285
+ .contact-item:last-child { border-bottom: none; }
286
+ .contact-item:active { opacity: 0.7; }
287
+ .contact-icon {
288
+ width: 40px; height: 40px;
289
+ border-radius: 10px;
290
+ background: var(--primary-dim);
291
+ display: flex; align-items: center; justify-content: center;
292
+ flex-shrink: 0;
293
+ }
294
+ .contact-icon svg {
295
+ width: 18px; height: 18px;
296
+ stroke: var(--primary);
297
+ fill: none; stroke-width: 1.5;
298
+ }
299
+ .contact-text .label {
300
+ font-size: 10px;
301
+ text-transform: uppercase;
302
+ letter-spacing: 0.12em;
303
+ color: var(--text-muted);
304
+ font-weight: 600;
305
+ margin-bottom: 4px;
306
+ }
307
+ .contact-text .value {
308
+ font-size: 14px;
309
+ color: var(--text);
310
+ font-weight: 400;
311
+ }
312
+
313
+ /* ====== MENU PAGE ====== */
314
+ .menu-header {
315
+ padding: 28px 28px 20px;
316
+ background: var(--bg);
317
+ border-bottom: 1px solid var(--border);
318
+ position: sticky; top: 0; z-index: 50;
319
+ }
320
+ .menu-header h1 {
321
+ font-family: 'Cinzel', serif;
322
+ font-size: 28px;
323
+ font-weight: 600;
324
+ color: var(--text);
325
+ letter-spacing: 0.04em;
326
+ margin-bottom: 16px;
327
+ }
328
+
329
+ .cat-tabs {
330
+ display: flex; gap: 8px;
331
+ overflow-x: auto; scrollbar-width: none;
332
+ -webkit-overflow-scrolling: touch; padding-bottom: 2px;
333
+ }
334
+ .cat-tabs::-webkit-scrollbar { display: none; }
335
+ .cat-tab {
336
+ padding: 8px 18px;
337
+ border-radius: 100px;
338
+ font-size: 12px; font-weight: 500;
339
+ color: var(--text-muted);
340
+ background: var(--bg-card);
341
+ border: 1px solid var(--border);
342
+ cursor: pointer; white-space: nowrap;
343
+ transition: all 0.25s ease;
344
+ letter-spacing: 0.04em;
345
+ text-transform: uppercase;
346
+ -webkit-tap-highlight-color: transparent;
347
+ }
348
+ .cat-tab.active {
349
+ background: var(--primary);
350
+ color: var(--bg);
351
+ border-color: var(--primary);
352
+ font-weight: 600;
353
+ }
354
+
355
+ .menu-list { padding: 16px 24px; }
356
+
357
+ .menu-category { margin-bottom: 28px; }
358
+ .menu-category-title {
359
+ font-family: 'Cinzel', serif;
360
+ font-size: 18px;
361
+ font-weight: 600;
362
+ color: var(--primary);
363
+ margin-bottom: 14px;
364
+ letter-spacing: 0.06em;
365
+ }
366
+
367
+ .menu-item {
368
+ display: flex;
369
+ gap: 14px;
370
+ padding: 14px;
371
+ background: var(--bg-card);
372
+ border-radius: 14px;
373
+ margin-bottom: 10px;
374
+ border: 1px solid var(--border);
375
+ transition: transform 0.2s ease;
376
+ }
377
+ .menu-item:active { transform: scale(0.985); }
378
+
379
+ .menu-item-img {
380
+ width: 76px; height: 76px;
381
+ border-radius: 12px;
382
+ object-fit: cover;
383
+ flex-shrink: 0;
384
+ background: var(--bg-elevated);
385
+ }
386
+
387
+ .menu-item-info {
388
+ flex: 1;
389
+ display: flex; flex-direction: column; justify-content: center;
390
+ min-width: 0;
391
+ }
392
+ .menu-item-name {
393
+ font-size: 14px; font-weight: 600;
394
+ color: var(--text); margin-bottom: 4px;
395
+ letter-spacing: 0.02em;
396
+ }
397
+ .menu-item-desc {
398
+ font-size: 12px; color: var(--text-muted);
399
+ line-height: 1.4; margin-bottom: 6px;
400
+ display: -webkit-box;
401
+ -webkit-line-clamp: 2;
402
+ -webkit-box-orient: vertical;
403
+ overflow: hidden;
404
+ font-weight: 300;
405
+ }
406
+ .menu-item-price {
407
+ font-size: 16px; font-weight: 600;
408
+ color: var(--primary);
409
+ font-family: 'Cinzel', serif;
410
+ letter-spacing: 0.02em;
411
+ }
412
+
413
+ .spacer-lg { height: 28px; }
414
+ </style>
415
+ </head>
416
+ <body>
417
+
418
+ <!-- HOME PAGE -->
419
+ <div class="page active" id="page-home">
420
+ <!-- {{HOME_BLOCKS}} -->
421
+ </div>
422
+
423
+ <!-- MENU PAGE -->
424
+ <div class="page" id="page-menu">
425
+ <div class="menu-header">
426
+ <h1>Menu</h1>
427
+ <div class="cat-tabs" id="cat-tabs">
428
+ <div class="cat-tab active" data-cat="all">All</div>
429
+ <!-- {{CATEGORY_TABS}} -->
430
+ </div>
431
+ </div>
432
+ <div class="menu-list" id="menu-list">
433
+ <!-- {{MENU_ITEMS}} -->
434
+ </div>
435
+ </div>
436
+
437
+ <!-- BOTTOM NAV -->
438
+ <nav class="bottom-nav">
439
+ <div class="nav-item active" data-page="home" onclick="switchPage('home')">
440
+ <svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
441
+ <span>Home</span>
442
+ </div>
443
+ <div class="nav-item" data-page="menu" onclick="switchPage('menu')">
444
+ <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
445
+ <span>Menu</span>
446
+ </div>
447
+ </nav>
448
+
449
+ <script>
450
+ function switchPage(page) {
451
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
452
+ document.getElementById('page-' + page).classList.add('active');
453
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
454
+ document.querySelector('.nav-item[data-page="' + page + '"]').classList.add('active');
455
+ window.scrollTo({ top: 0, behavior: 'smooth' });
456
+ }
457
+
458
+ document.addEventListener('click', function(e) {
459
+ if (e.target.classList.contains('cat-tab')) {
460
+ document.querySelectorAll('.cat-tab').forEach(t => t.classList.remove('active'));
461
+ e.target.classList.add('active');
462
+ var cat = e.target.getAttribute('data-cat');
463
+ document.querySelectorAll('.menu-category').forEach(function(section) {
464
+ section.style.display = (cat === 'all' || section.getAttribute('data-cat') === cat) ? 'block' : 'none';
465
+ });
466
+ }
467
+ });
468
+ </script>
469
+ </body>
470
+ </html>
templates/warm.html ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <title>{{SHOP_NAME}}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --primary: #6B4226;
12
+ --primary-light: #8B6914;
13
+ --secondary: #D4A574;
14
+ --accent: #C8553D;
15
+ --bg: #FEF9F3;
16
+ --bg-warm: #F5EDE3;
17
+ --text: #3E2723;
18
+ --text-light: #795548;
19
+ --text-muted: #A1887F;
20
+ --white: #FFFDF9;
21
+ --card-shadow: 0 2px 20px rgba(62,39,35,0.08);
22
+ --card-radius: 18px;
23
+ --nav-height: 68px;
24
+ }
25
+
26
+ * { margin: 0; padding: 0; box-sizing: border-box; }
27
+
28
+ body {
29
+ font-family: 'Nunito', sans-serif;
30
+ background: var(--bg);
31
+ color: var(--text);
32
+ max-width: 480px;
33
+ margin: 0 auto;
34
+ min-height: 100vh;
35
+ padding-bottom: calc(var(--nav-height) + 16px);
36
+ -webkit-font-smoothing: antialiased;
37
+ overflow-x: hidden;
38
+ }
39
+
40
+ /* ====== PAGE SYSTEM ====== */
41
+ .page { display: none; animation: fadeUp 0.4s ease; }
42
+ .page.active { display: block; }
43
+
44
+ @keyframes fadeUp {
45
+ from { opacity: 0; transform: translateY(12px); }
46
+ to { opacity: 1; transform: translateY(0); }
47
+ }
48
+
49
+ /* ====== BOTTOM NAV ====== */
50
+ .bottom-nav {
51
+ position: fixed;
52
+ bottom: 0;
53
+ left: 50%;
54
+ transform: translateX(-50%);
55
+ width: 100%;
56
+ max-width: 480px;
57
+ height: var(--nav-height);
58
+ background: var(--white);
59
+ border-top: 1px solid rgba(62,39,35,0.06);
60
+ display: flex;
61
+ justify-content: space-around;
62
+ align-items: center;
63
+ z-index: 100;
64
+ box-shadow: 0 -4px 24px rgba(62,39,35,0.06);
65
+ backdrop-filter: blur(12px);
66
+ padding-bottom: env(safe-area-inset-bottom, 0);
67
+ }
68
+
69
+ .nav-item {
70
+ display: flex;
71
+ flex-direction: column;
72
+ align-items: center;
73
+ gap: 3px;
74
+ cursor: pointer;
75
+ padding: 8px 20px;
76
+ border-radius: 14px;
77
+ transition: all 0.25s ease;
78
+ -webkit-tap-highlight-color: transparent;
79
+ user-select: none;
80
+ }
81
+
82
+ .nav-item svg {
83
+ width: 24px;
84
+ height: 24px;
85
+ stroke: var(--text-muted);
86
+ fill: none;
87
+ stroke-width: 1.8;
88
+ transition: all 0.25s ease;
89
+ }
90
+
91
+ .nav-item span {
92
+ font-size: 11px;
93
+ font-weight: 600;
94
+ color: var(--text-muted);
95
+ letter-spacing: 0.02em;
96
+ transition: color 0.25s ease;
97
+ }
98
+
99
+ .nav-item.active {
100
+ background: linear-gradient(135deg, rgba(107,66,38,0.08), rgba(212,165,116,0.12));
101
+ }
102
+
103
+ .nav-item.active svg { stroke: var(--primary); }
104
+ .nav-item.active span { color: var(--primary); }
105
+
106
+ /* ====== HOME PAGE ====== */
107
+
108
+ /* -- Hero / Brand Banner -- */
109
+ .hero {
110
+ position: relative;
111
+ width: 100%;
112
+ height: 380px;
113
+ background: linear-gradient(165deg, var(--primary) 0%, #4A2C17 100%);
114
+ display: flex;
115
+ flex-direction: column;
116
+ justify-content: flex-end;
117
+ padding: 32px 24px;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .hero::before {
122
+ content: '';
123
+ position: absolute;
124
+ top: -40%;
125
+ right: -30%;
126
+ width: 300px;
127
+ height: 300px;
128
+ border-radius: 50%;
129
+ background: rgba(212,165,116,0.12);
130
+ }
131
+
132
+ .hero::after {
133
+ content: '';
134
+ position: absolute;
135
+ bottom: -20%;
136
+ left: -20%;
137
+ width: 200px;
138
+ height: 200px;
139
+ border-radius: 50%;
140
+ background: rgba(200,85,61,0.08);
141
+ }
142
+
143
+ .hero-badge {
144
+ display: inline-flex;
145
+ align-items: center;
146
+ gap: 6px;
147
+ background: rgba(255,255,255,0.12);
148
+ backdrop-filter: blur(8px);
149
+ padding: 6px 14px;
150
+ border-radius: 100px;
151
+ font-size: 12px;
152
+ color: var(--secondary);
153
+ font-weight: 600;
154
+ letter-spacing: 0.06em;
155
+ text-transform: uppercase;
156
+ margin-bottom: 16px;
157
+ width: fit-content;
158
+ border: 1px solid rgba(255,255,255,0.08);
159
+ position: relative;
160
+ z-index: 1;
161
+ }
162
+
163
+ .hero-badge svg {
164
+ width: 14px;
165
+ height: 14px;
166
+ fill: var(--secondary);
167
+ stroke: none;
168
+ }
169
+
170
+ .hero h1 {
171
+ font-family: 'Cormorant Garamond', serif;
172
+ font-size: 42px;
173
+ font-weight: 700;
174
+ color: var(--white);
175
+ line-height: 1.05;
176
+ margin-bottom: 8px;
177
+ position: relative;
178
+ z-index: 1;
179
+ letter-spacing: -0.01em;
180
+ }
181
+
182
+ .hero-tagline {
183
+ font-size: 15px;
184
+ color: rgba(255,253,249,0.7);
185
+ line-height: 1.5;
186
+ position: relative;
187
+ z-index: 1;
188
+ max-width: 300px;
189
+ }
190
+
191
+ /* -- Section Titles -- */
192
+ .section-title {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 12px;
196
+ padding: 0 24px;
197
+ margin: 32px 0 16px;
198
+ }
199
+
200
+ .section-title h2 {
201
+ font-family: 'Cormorant Garamond', serif;
202
+ font-size: 26px;
203
+ font-weight: 700;
204
+ color: var(--text);
205
+ letter-spacing: -0.01em;
206
+ }
207
+
208
+ .section-title .line {
209
+ flex: 1;
210
+ height: 1px;
211
+ background: linear-gradient(90deg, var(--secondary) 0%, transparent 100%);
212
+ }
213
+
214
+ /* -- Promo Banner / Event Poster -- */
215
+ .promo-banner {
216
+ margin: 0 20px;
217
+ border-radius: var(--card-radius);
218
+ overflow: hidden;
219
+ position: relative;
220
+ box-shadow: var(--card-shadow);
221
+ }
222
+
223
+ .promo-banner img {
224
+ width: 100%;
225
+ height: auto;
226
+ display: block;
227
+ }
228
+
229
+ .promo-banner .promo-placeholder {
230
+ width: 100%;
231
+ height: 200px;
232
+ background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 50%, var(--secondary) 100%);
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ flex-direction: column;
237
+ gap: 8px;
238
+ }
239
+
240
+ .promo-banner .promo-placeholder span {
241
+ color: var(--white);
242
+ font-family: 'Cormorant Garamond', serif;
243
+ font-size: 24px;
244
+ font-weight: 700;
245
+ }
246
+
247
+ .promo-banner .promo-placeholder small {
248
+ color: rgba(255,253,249,0.7);
249
+ font-size: 13px;
250
+ }
251
+
252
+ /* -- About Section -- */
253
+ .about-card {
254
+ margin: 0 20px;
255
+ padding: 24px;
256
+ background: var(--white);
257
+ border-radius: var(--card-radius);
258
+ box-shadow: var(--card-shadow);
259
+ border: 1px solid rgba(62,39,35,0.04);
260
+ }
261
+
262
+ .about-card p {
263
+ font-size: 14.5px;
264
+ line-height: 1.7;
265
+ color: var(--text-light);
266
+ }
267
+
268
+ .about-card .highlight {
269
+ display: inline;
270
+ background: linear-gradient(180deg, transparent 60%, rgba(212,165,116,0.25) 60%);
271
+ font-weight: 600;
272
+ color: var(--text);
273
+ }
274
+
275
+ /* -- Quick Info Pills -- */
276
+ .info-pills {
277
+ display: flex;
278
+ gap: 10px;
279
+ padding: 0 20px;
280
+ margin: 20px 0;
281
+ flex-wrap: wrap;
282
+ }
283
+
284
+ .pill {
285
+ display: inline-flex;
286
+ align-items: center;
287
+ gap: 6px;
288
+ padding: 10px 16px;
289
+ background: var(--white);
290
+ border-radius: 100px;
291
+ font-size: 13px;
292
+ font-weight: 600;
293
+ color: var(--text-light);
294
+ box-shadow: 0 2px 12px rgba(62,39,35,0.05);
295
+ border: 1px solid rgba(62,39,35,0.04);
296
+ white-space: nowrap;
297
+ }
298
+
299
+ .pill svg {
300
+ width: 16px;
301
+ height: 16px;
302
+ stroke: var(--primary);
303
+ fill: none;
304
+ stroke-width: 2;
305
+ flex-shrink: 0;
306
+ }
307
+
308
+ /* -- Contact Footer -- */
309
+ .contact-section {
310
+ margin: 32px 20px 16px;
311
+ padding: 24px;
312
+ background: var(--white);
313
+ border-radius: var(--card-radius);
314
+ box-shadow: var(--card-shadow);
315
+ border: 1px solid rgba(62,39,35,0.04);
316
+ }
317
+
318
+ .contact-item {
319
+ display: flex;
320
+ align-items: flex-start;
321
+ gap: 14px;
322
+ padding: 14px 0;
323
+ border-bottom: 1px solid rgba(62,39,35,0.05);
324
+ text-decoration: none;
325
+ color: inherit;
326
+ transition: opacity 0.2s;
327
+ }
328
+
329
+ .contact-item:last-child { border-bottom: none; }
330
+ .contact-item:active { opacity: 0.7; }
331
+
332
+ .contact-icon {
333
+ width: 40px;
334
+ height: 40px;
335
+ border-radius: 12px;
336
+ background: var(--bg-warm);
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ flex-shrink: 0;
341
+ }
342
+
343
+ .contact-icon svg {
344
+ width: 18px;
345
+ height: 18px;
346
+ stroke: var(--primary);
347
+ fill: none;
348
+ stroke-width: 2;
349
+ }
350
+
351
+ .contact-text { flex: 1; }
352
+ .contact-text .label {
353
+ font-size: 11px;
354
+ text-transform: uppercase;
355
+ letter-spacing: 0.08em;
356
+ color: var(--text-muted);
357
+ font-weight: 700;
358
+ margin-bottom: 3px;
359
+ }
360
+ .contact-text .value {
361
+ font-size: 14px;
362
+ color: var(--text);
363
+ font-weight: 500;
364
+ line-height: 1.4;
365
+ }
366
+
367
+ /* ====== MENU PAGE ====== */
368
+
369
+ .menu-header {
370
+ padding: 28px 24px 20px;
371
+ background: var(--white);
372
+ border-bottom: 1px solid rgba(62,39,35,0.05);
373
+ position: sticky;
374
+ top: 0;
375
+ z-index: 50;
376
+ }
377
+
378
+ .menu-header h1 {
379
+ font-family: 'Cormorant Garamond', serif;
380
+ font-size: 32px;
381
+ font-weight: 700;
382
+ color: var(--text);
383
+ margin-bottom: 14px;
384
+ }
385
+
386
+ /* Category filter tabs */
387
+ .cat-tabs {
388
+ display: flex;
389
+ gap: 8px;
390
+ overflow-x: auto;
391
+ scrollbar-width: none;
392
+ -webkit-overflow-scrolling: touch;
393
+ padding-bottom: 2px;
394
+ }
395
+ .cat-tabs::-webkit-scrollbar { display: none; }
396
+
397
+ .cat-tab {
398
+ padding: 8px 18px;
399
+ border-radius: 100px;
400
+ font-size: 13px;
401
+ font-weight: 600;
402
+ color: var(--text-muted);
403
+ background: var(--bg);
404
+ border: 1.5px solid rgba(62,39,35,0.06);
405
+ cursor: pointer;
406
+ white-space: nowrap;
407
+ transition: all 0.25s ease;
408
+ -webkit-tap-highlight-color: transparent;
409
+ }
410
+
411
+ .cat-tab.active {
412
+ background: var(--primary);
413
+ color: var(--white);
414
+ border-color: var(--primary);
415
+ }
416
+
417
+ /* Menu Items Grid */
418
+ .menu-list {
419
+ padding: 16px 20px;
420
+ }
421
+
422
+ .menu-category {
423
+ margin-bottom: 28px;
424
+ }
425
+
426
+ .menu-category-title {
427
+ font-family: 'Cormorant Garamond', serif;
428
+ font-size: 22px;
429
+ font-weight: 700;
430
+ color: var(--text);
431
+ margin-bottom: 14px;
432
+ padding-left: 4px;
433
+ }
434
+
435
+ .menu-item {
436
+ display: flex;
437
+ gap: 14px;
438
+ padding: 14px;
439
+ background: var(--white);
440
+ border-radius: 16px;
441
+ margin-bottom: 10px;
442
+ box-shadow: 0 1px 8px rgba(62,39,35,0.04);
443
+ border: 1px solid rgba(62,39,35,0.03);
444
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
445
+ }
446
+
447
+ .menu-item:active {
448
+ transform: scale(0.985);
449
+ box-shadow: 0 1px 4px rgba(62,39,35,0.06);
450
+ }
451
+
452
+ .menu-item-img {
453
+ width: 80px;
454
+ height: 80px;
455
+ border-radius: 14px;
456
+ object-fit: cover;
457
+ flex-shrink: 0;
458
+ background: var(--bg-warm);
459
+ }
460
+
461
+ .menu-item-info {
462
+ flex: 1;
463
+ display: flex;
464
+ flex-direction: column;
465
+ justify-content: center;
466
+ min-width: 0;
467
+ }
468
+
469
+ .menu-item-name {
470
+ font-size: 15px;
471
+ font-weight: 700;
472
+ color: var(--text);
473
+ margin-bottom: 4px;
474
+ line-height: 1.3;
475
+ }
476
+
477
+ .menu-item-desc {
478
+ font-size: 12.5px;
479
+ color: var(--text-muted);
480
+ line-height: 1.4;
481
+ margin-bottom: 6px;
482
+ display: -webkit-box;
483
+ -webkit-line-clamp: 2;
484
+ -webkit-box-orient: vertical;
485
+ overflow: hidden;
486
+ }
487
+
488
+ .menu-item-price {
489
+ font-size: 16px;
490
+ font-weight: 700;
491
+ color: var(--primary);
492
+ font-family: 'Cormorant Garamond', serif;
493
+ }
494
+
495
+ /* ====== UTILITY ====== */
496
+ .spacer-sm { height: 8px; }
497
+ .spacer-md { height: 16px; }
498
+ .spacer-lg { height: 28px; }
499
+
500
+ </style>
501
+ </head>
502
+ <body>
503
+
504
+ <!-- ==================== HOME PAGE ==================== -->
505
+ <div class="page active" id="page-home">
506
+ <!-- {{HOME_BLOCKS}} -->
507
+ </div>
508
+
509
+ <!-- ==================== MENU PAGE ==================== -->
510
+ <div class="page" id="page-menu">
511
+
512
+ <div class="menu-header">
513
+ <h1>Our Menu</h1>
514
+ <div class="cat-tabs" id="cat-tabs">
515
+ <div class="cat-tab active" data-cat="all">All</div>
516
+ <!-- {{CATEGORY_TABS}} -->
517
+ </div>
518
+ </div>
519
+
520
+ <div class="menu-list" id="menu-list">
521
+ <!-- {{MENU_ITEMS}} -->
522
+ </div>
523
+
524
+ </div>
525
+
526
+ <!-- ==================== BOTTOM NAV ==================== -->
527
+ <nav class="bottom-nav">
528
+ <div class="nav-item active" data-page="home" onclick="switchPage('home')">
529
+ <svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
530
+ <span>Home</span>
531
+ </div>
532
+ <div class="nav-item" data-page="menu" onclick="switchPage('menu')">
533
+ <svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
534
+ <span>Menu</span>
535
+ </div>
536
+ </nav>
537
+
538
+ <script>
539
+ // ====== PAGE NAVIGATION ======
540
+ function switchPage(page) {
541
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
542
+ document.getElementById('page-' + page).classList.add('active');
543
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
544
+ document.querySelector('.nav-item[data-page="' + page + '"]').classList.add('active');
545
+ window.scrollTo({ top: 0, behavior: 'smooth' });
546
+ }
547
+
548
+ // ====== CATEGORY FILTER ======
549
+ document.addEventListener('click', function(e) {
550
+ if (e.target.classList.contains('cat-tab')) {
551
+ document.querySelectorAll('.cat-tab').forEach(t => t.classList.remove('active'));
552
+ e.target.classList.add('active');
553
+ var cat = e.target.getAttribute('data-cat');
554
+ document.querySelectorAll('.menu-category').forEach(function(section) {
555
+ if (cat === 'all' || section.getAttribute('data-cat') === cat) {
556
+ section.style.display = 'block';
557
+ } else {
558
+ section.style.display = 'none';
559
+ }
560
+ });
561
+ }
562
+ });
563
+ </script>
564
+
565
+ </body>
566
+ </html>