File size: 11,379 Bytes
16e1aa7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """
Gradio Blocks UI, mounted onto the FastAPI app in main.py.
Design choice: the UI talks to the *same* FastAPI routes (auth, generate,
brand-voice, history, billing) via an in-process ASGI HTTP client, rather
than calling the underlying Python functions directly. This means every rule
enforced in the routes (rate limits, tier gating, quota checks, validation)
applies identically whether a request comes from the UI or from a future
external API consumer - no duplicated / drifting business logic.
"""
import httpx
import gradio as gr
from app.config import DEMO_MODE, TIERS, Tier
def _client(app) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://internal")
def build_ui(app) -> gr.Blocks:
with gr.Blocks(title="Etsy Listing Optimizer") as demo:
token_state = gr.State(value=None)
email_state = gr.State(value=None)
gr.Markdown("# 🏷️ Etsy Listing Optimizer")
if DEMO_MODE:
gr.Markdown(
"⚠️ **Demo mode**: no real Supabase/Groq/Gemini/Stripe credentials are configured, "
"so this is running on in-memory storage and a mock AI provider that returns "
"already-valid sample output. Add real API keys (see `.env.example` / `DEPLOY.md`) "
"to go live. Signup/login works with a local in-memory demo auth store — data is "
"lost on restart."
)
with gr.Tab("Account"):
gr.Markdown("### Sign up or log in")
with gr.Row():
email_box = gr.Textbox(label="Email")
password_box = gr.Textbox(label="Password", type="password")
with gr.Row():
signup_btn = gr.Button("Sign up")
login_btn = gr.Button("Log in")
logout_btn = gr.Button("Log out")
auth_status = gr.Markdown("Not logged in.")
async def do_signup(email, password):
async with _client(app) as client:
resp = await client.post("/auth/signup", json={"email": email, "password": password})
if resp.status_code != 200:
return None, None, f"❌ {resp.json().get('detail', 'Sign up failed.')}"
data = resp.json()
return data["access_token"], data["email"], f"✅ Signed up and logged in as {data['email']}."
async def do_login(email, password):
async with _client(app) as client:
resp = await client.post("/auth/login", json={"email": email, "password": password})
if resp.status_code != 200:
return None, None, f"❌ {resp.json().get('detail', 'Login failed.')}"
data = resp.json()
return data["access_token"], data["email"], f"✅ Logged in as {data['email']}."
def do_logout():
return None, None, "Logged out."
signup_btn.click(do_signup, [email_box, password_box], [token_state, email_state, auth_status])
login_btn.click(do_login, [email_box, password_box], [token_state, email_state, auth_status])
logout_btn.click(do_logout, None, [token_state, email_state, auth_status])
gr.Markdown("### Plans")
plan_lines = []
for tier in [Tier.FREE, Tier.STARTER, Tier.PRO, Tier.BUSINESS]:
c = TIERS[tier]
variants = f"{c.title_variants} title variant(s)"
gens = "unlimited generations" if c.generations_per_month == -1 else f"{c.generations_per_month} generations/mo"
plan_lines.append(f"- **{c.display_name}** ({c.price_czk} CZK/mo): {gens}, {variants}")
gr.Markdown("\n".join(plan_lines))
with gr.Row():
upgrade_tier = gr.Dropdown(
choices=[Tier.STARTER.value, Tier.PRO.value, Tier.BUSINESS.value],
label="Upgrade to",
value=Tier.STARTER.value,
)
upgrade_btn = gr.Button("Get checkout link")
checkout_output = gr.Markdown()
async def do_checkout(tier, token):
if not token:
return "Log in first."
async with _client(app) as client:
resp = await client.post(
f"/billing/checkout/{tier}", headers={"Authorization": f"Bearer {token}"}
)
if resp.status_code != 200:
return f"❌ {resp.json().get('detail', 'Checkout unavailable.')}"
return f"[Click here to complete checkout]({resp.json()['checkout_url']})"
upgrade_btn.click(do_checkout, [upgrade_tier, token_state], checkout_output)
gr.Markdown(
"### Your data\n"
"Use the buttons below for GDPR data export / account deletion."
)
with gr.Row():
export_btn = gr.Button("Export my data (JSON)")
delete_btn = gr.Button("🗑️ Delete my account", variant="stop")
account_status = gr.Markdown()
async def do_export(token):
if not token:
return "Log in first."
async with _client(app) as client:
resp = await client.get("/account/export", headers={"Authorization": f"Bearer {token}"})
return f"```json\n{resp.text}\n```"
async def do_delete(token):
if not token:
return None, None, "Log in first."
async with _client(app) as client:
resp = await client.delete("/account/delete", headers={"Authorization": f"Bearer {token}"})
if resp.status_code != 200:
return token, None, f"❌ {resp.json().get('detail', 'Delete failed.')}"
return None, None, "✅ Account and all data deleted."
export_btn.click(do_export, token_state, account_status)
delete_btn.click(do_delete, token_state, [token_state, email_state, account_status])
with gr.Tab("Generate listing"):
gr.Markdown(
"Describe your product in plain language. We'll generate SEO-optimized "
"title(s), exactly 13 tags, and a description — then validate everything "
"programmatically (length limits, tag count, no duplicate words) before "
"showing it to you."
)
product_description = gr.Textbox(
label="Product description",
placeholder="Hand-poured soy candle in a reused wine bottle, lavender and vanilla scent...",
lines=4,
)
target_keywords = gr.Textbox(label="Target keywords (optional)", placeholder="e.g. eco friendly gift")
generate_btn = gr.Button("Generate", variant="primary")
quota_md = gr.Markdown()
titles_out = gr.Textbox(label="Title variant(s)", lines=3, interactive=False)
tags_out = gr.Textbox(label="Tags (13)", lines=3, interactive=False)
description_out = gr.Textbox(label="Description", lines=6, interactive=False)
category_out = gr.Textbox(label="Category/attribute hints (Pro/Business)", interactive=False)
async def do_generate(description, keywords, token):
if not token:
return "Log in first (Account tab).", "", "", "", ""
async with _client(app) as client:
resp = await client.post(
"/generate",
json={"product_description": description, "target_keywords": keywords or None},
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code != 200:
detail = resp.json().get("detail", "Generation failed.")
return f"❌ {detail}", "", "", "", ""
data = resp.json()
quota = (
f"Used {data['generations_used_this_month']} / "
f"{data['generations_limit_this_month']} generations this month."
)
return (
quota,
"\n".join(f"{i+1}. {t}" for i, t in enumerate(data["titles"])),
", ".join(data["tags"]),
data["description"],
", ".join(data["category_hints"]) if data["category_hints"] else "(none for your plan)",
)
generate_btn.click(
do_generate,
[product_description, target_keywords, token_state],
[quota_md, titles_out, tags_out, description_out, category_out],
)
with gr.Tab("Brand voice"):
gr.Markdown("Set a brand voice profile to automatically flavor every generation.")
bv_name = gr.Textbox(label="Profile name", value="Default")
bv_tone = gr.Textbox(label="Tone (e.g. playful, elegant, minimalist, or free text)")
bv_audience = gr.Textbox(label="Target audience")
bv_words = gr.Textbox(label="Favorite words/phrases to reuse")
bv_save_btn = gr.Button("Save profile")
bv_status = gr.Markdown()
bv_list = gr.JSON(label="Your brand voice profiles")
async def save_brand_voice(name, tone, audience, words, token):
if not token:
return "Log in first.", []
async with _client(app) as client:
resp = await client.post(
"/brand-voice",
json={"name": name, "tone": tone, "target_audience": audience, "favorite_words": words},
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code != 200:
return f"❌ {resp.json().get('detail', 'Save failed.')}", []
listing = await client.get("/brand-voice", headers={"Authorization": f"Bearer {token}"})
return "✅ Saved.", listing.json()
bv_save_btn.click(
save_brand_voice, [bv_name, bv_tone, bv_audience, bv_words, token_state], [bv_status, bv_list]
)
with gr.Tab("History"):
gr.Markdown("Past generations (visibility depends on your plan).")
history_refresh_btn = gr.Button("Refresh history")
history_json = gr.JSON()
async def refresh_history(token):
if not token:
return {"error": "Log in first."}
async with _client(app) as client:
resp = await client.get("/history", headers={"Authorization": f"Bearer {token}"})
if resp.status_code != 200:
return {"error": resp.json().get("detail", "History unavailable.")}
return resp.json()
history_refresh_btn.click(refresh_history, token_state, history_json)
gr.Markdown(
"[Privacy Policy](/legal/privacy) · [Terms of Service](/legal/terms) · "
"[Refund Policy](/legal/refunds)"
)
return demo
|