diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py index e9ca07a..3b07fd8 100644 --- a/events/sexp/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -24,10 +24,12 @@ from shared.sexp.helpers import ( def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: """Wrap a header row in OOB div with child placeholder.""" - return ( - f'
' - f'
{row_html}' - f'
' + return sexp( + '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"' + ' (div :class "w-full"' + ' (raw! rh)' + ' (div :id cid)))', + pid=parent_id, cid=child_id, rh=row_html, ) @@ -42,24 +44,23 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: title = (post.get("title") or "")[:160] feature_image = post.get("feature_image") - label_parts = [] - if feature_image: - label_parts.append( - f'' - ) - label_parts.append(f"{escape(title)}") - label_html = "".join(label_parts) + label_html = sexp( + '(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))' + ' (span t))', + fi=feature_image, t=title, + ) nav_parts = [] page_cart_count = ctx.get("page_cart_count", 0) if page_cart_count and page_cart_count > 0: cart_href = call_url(ctx, "cart_url", f"/{slug}/") - nav_parts.append( - f'' - f'' - f'{page_cart_count}' - ) + nav_parts.append(sexp( + '(a :href ch :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full' + ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"' + ' (i :class "fa fa-shopping-cart" :aria-hidden "true")' + ' (span cnt))', + ch=cart_href, cnt=str(page_cart_count), + )) # Post nav: calendar links + admin nav_parts.append(_post_nav_html(ctx)) @@ -124,7 +125,7 @@ def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str: ' :link-href lh :link-label-html llh' ' :child-id "calendars-header-child" :oob oob)', lh=link_href, - llh='
Calendars
', + llh=sexp('(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))'), oob=oob, ) @@ -144,14 +145,15 @@ def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str: cal_desc = getattr(calendar, "description", "") or "" link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug) - label_html = ( - '
' - '
' - f'' - f'
{escape(cal_name)}
' - '
' - f'
{escape(cal_desc)}
' - '
' + label_html = sexp( + '(div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0"' + ' (div :class "flex flex-row items-center gap-2"' + ' (i :class "fa fa-calendar")' + ' (div :class "shrink-0" n))' + ' (div :id "calendar-description-title"' + ' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"' + ' d))', + n=cal_name, d=cal_desc, ) # Desktop nav: slots + admin @@ -188,10 +190,10 @@ def _calendar_nav_html(ctx: dict) -> str: )) if is_admin: admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug) - parts.append( - f'' - f'' - ) + parts.append(sexp( + '(~nav-link :href h :icon "fa fa-cog" :select-colours sc)', + h=admin_href, sc=select_colours, + )) return "".join(parts) @@ -217,11 +219,11 @@ def _day_header_html(ctx: dict, *, oob: bool = False) -> str: month=day_date.month, day=day_date.day, ) - label_html = ( - '
' - f'' - f' {escape(day_date.strftime("%A %d %B %Y"))}' - '
' + label_html = sexp( + '(div :class "flex gap-1 items-center"' + ' (i :class "fa fa-calendar-day")' + ' (span d))', + d=day_date.strftime("%A %d %B %Y"), ) nav_html = _day_nav_html(ctx) @@ -252,11 +254,7 @@ def _day_nav_html(ctx: dict) -> str: parts = [] # Confirmed entries nav (scrolling menu) if confirmed_entries: - parts.append( - '
' - '
' - ) + entry_links = [] for entry in confirmed_entries: href = url_for( "calendars.calendar.day.calendar_entries.calendar_entry.get", @@ -266,17 +264,23 @@ def _day_nav_html(ctx: dict) -> str: day=day_date.day, entry_id=entry.id, ) - name = escape(entry.name) start = entry.start_at.strftime("%H:%M") if entry.start_at else "" end = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - parts.append( - f'' - f'
' - f'
{name}
' - f'
{start}{end}
' - f'
' - ) - parts.append('
') + entry_links.append(sexp( + '(a :href h :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"' + ' (div :class "flex-1 min-w-0"' + ' (div :class "font-medium truncate" n)' + ' (div :class "text-xs text-stone-600 truncate" t)))', + h=href, n=entry.name, t=f"{start}{end}", + )) + inner = "".join(entry_links) + parts.append(sexp( + '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' + ' :id "day-entries-nav-wrapper"' + ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin"' + ' (raw! inner)))', + inner=inner, + )) if is_admin and day_date: admin_href = url_for( @@ -286,10 +290,10 @@ def _day_nav_html(ctx: dict) -> str: month=day_date.month, day=day_date.day, ) - parts.append( - f'' - f'' - ) + parts.append(sexp( + '(~nav-link :href h :icon "fa fa-cog")', + h=admin_href, + )) return "".join(parts) @@ -370,7 +374,7 @@ def _markets_header_html(ctx: dict, *, oob: bool = False) -> str: ' :link-href lh :link-label-html llh' ' :child-id "markets-header-child" :oob oob)', lh=link_href, - llh='
Markets
', + llh=sexp('(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))'), oob=oob, ) @@ -388,7 +392,7 @@ def _payments_header_html(ctx: dict, *, oob: bool = False) -> str: ' :link-href lh :link-label-html llh' ' :child-id "payments-header-child" :oob oob)', lh=link_href, - llh='
Payments
', + llh=sexp('(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments"))'), oob=oob, ) @@ -408,41 +412,45 @@ def _calendars_main_panel_html(ctx: dict) -> str: csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") calendars = ctx.get("calendars") or [] - hx_select = ctx.get("hx_select_search", "#main-panel") - parts = ['
'] + form_html = "" if can_create: create_url = url_for("calendars.create_calendar") - parts.append( - '
' - f'
""" - f'' - '
' - '
' - '
' + form_html = sexp( + '(<>' + ' (div :id "cal-create-errors" :class "mt-2 text-sm text-red-600")' + ' (form :class "mt-4 flex gap-2 items-end" :hx-post cu' + ' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"' + """ :hx-on::before-request "document.querySelector('#cal-create-errors').textContent='';" """ + """ :hx-on::response-error "document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;" """ + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div :class "flex-1"' + ' (label :class "block text-sm text-gray-600" "Name")' + ' (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"' + ' :placeholder "e.g. Events, Gigs, Meetings"))' + ' (button :type "submit" :class "border rounded px-3 py-2" "Add calendar")))', + cu=create_url, csrf=csrf, ) - parts.append('
') - parts.append(_calendars_list_html(ctx, calendars)) - parts.append('
') - return "".join(parts) + list_html = _calendars_list_html(ctx, calendars) + return sexp( + '(section :class "p-4"' + ' (raw! fh)' + ' (div :id "calendars-list" :class "mt-6" (raw! lh)))', + fh=form_html, lh=list_html, + ) def _calendars_list_html(ctx: dict, calendars: list) -> str: """Render the calendars list items.""" from quart import url_for from shared.utils import route_prefix - hx_select = ctx.get("hx_select_search", "#main-panel") csrf_token = ctx.get("csrf_token") csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") prefix = route_prefix() if not calendars: - return '

No calendars yet. Create one above.

' + return sexp('(p :class "text-gray-500 mt-4" "No calendars yet. Create one above.")') parts = [] for cal in calendars: @@ -450,22 +458,25 @@ def _calendars_list_html(ctx: dict, calendars: list) -> str: cal_name = getattr(cal, "name", "") href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug) del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug) - parts.append( - f'
' - f'' - f'

{escape(cal_name)}

' - f'

/{escape(cal_slug)}/

' - f'
' - ) + csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + parts.append(sexp( + '(div :class "mt-6 border rounded-lg p-4"' + ' (div :class "flex items-center justify-between gap-3"' + ' (a :class "flex items-baseline gap-3" :href h' + ' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true"' + ' (h3 :class "font-semibold" cn)' + ' (h4 :class "text-gray-500" (str "/" cs "/")))' + ' (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"' + ' :data-confirm true :data-confirm-title "Delete calendar?"' + ' :data-confirm-text "Entries will be hidden (soft delete)"' + ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"' + ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' + ' :hx-delete du :hx-trigger "confirmed"' + ' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"' + ' :hx-headers ch' + ' (i :class "fa-solid fa-trash"))))', + h=href, cn=cal_name, cs=cal_slug, du=del_url, ch=csrf_hdr, + )) return "".join(parts) @@ -502,50 +513,39 @@ def _calendar_main_panel_html(ctx: dict) -> str: qs = qsession if "qsession" not in ctx else ctx["qsession"] def nav_link(y, m): - href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m) - return href + return url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m) - # Month navigation header - parts = ['
'] - parts.append('
') - parts.append('
') + nav_arrows.append(sexp( + '(a :class (str pc " text-xl") :href h' + ' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)', + pc=pill_cls, h=href, l=label, + )) - # Calendar grid - parts.append('
') # Weekday headers - parts.append('') + wd_html = "".join(sexp('(div :class "py-1" w)', w=wd) for wd in weekday_names) - # Weeks grid - parts.append('
') + # Day cells + cells = [] for week in weeks: for day_cell in week: if isinstance(day_cell, dict): @@ -563,29 +563,27 @@ def _calendar_main_panel_html(ctx: dict) -> str: if is_today: cell_cls += " ring-2 ring-blue-500 z-10 relative" - parts.append(f'
') - parts.append('
') - if day_date: - parts.append(f'{day_date.strftime("%a")}') - - # Clickable day number + # Day number link + day_num_html = "" + day_short_html = "" if day_date: day_href = url_for( "calendars.calendar.day.show_day", calendar_slug=cal_slug, - year=day_date.year, - month=day_date.month, - day=day_date.day, + year=day_date.year, month=day_date.month, day=day_date.day, ) - parts.append( - f'{day_date.day}' + day_short_html = sexp( + '(span :class "sm:hidden text-[16px] text-stone-500" d)', + d=day_date.strftime("%a"), + ) + day_num_html = sexp( + '(a :class pc :href h :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"' + ' :hx-swap "outerHTML" :hx-push-url "true" n)', + pc=pill_cls, h=day_href, n=str(day_date.day), ) - parts.append('
') - # Entries for this day - parts.append('
') + # Entry badges for this day + entry_badges = [] if day_date: for e in month_entries: if e.start_at and e.start_at.date() == day_date: @@ -597,18 +595,34 @@ def _calendar_main_panel_html(ctx: dict) -> str: bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800" else: bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700" - state_label = (e.state or "pending").replace("_", " ") - parts.append( - f'
' - f'{escape(e.name)}' - f'{state_label}' - f'
' - ) - parts.append('
') + entry_badges.append(sexp( + '(div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bc)' + ' (span :class "truncate" n)' + ' (span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" sl))', + bc=bg_cls, n=e.name, sl=state_label, + )) - parts.append('
') - return "".join(parts) + badges_html = "".join(entry_badges) + cells.append(sexp( + '(div :class cc' + ' (div :class "flex justify-between items-center"' + ' (div :class "flex flex-col" (raw! dsh) (raw! dnh)))' + ' (div :class "mt-1 space-y-0.5" (raw! bh)))', + cc=cell_cls, dsh=day_short_html, dnh=day_num_html, bh=badges_html, + )) + + cells_html = "".join(cells) + arrows_html = "".join(nav_arrows) + return sexp( + '(section :class "bg-orange-100"' + ' (header :class "flex items-center justify-center mt-2"' + ' (nav :class "flex items-center gap-2 text-2xl" (raw! ah)))' + ' (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4"' + ' (div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" (raw! wh))' + ' (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" (raw! ch))))', + ah=arrows_html, wh=wd_html, ch=cells_html, + ) # --------------------------------------------------------------------------- @@ -634,40 +648,36 @@ def _day_main_panel_html(ctx: dict) -> str: tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "") pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "") - parts = [f'
'] - parts.append( - '' - '' - '' - '' - '' - '' - '' - '' - ) - + rows_html = "" if day_entries: - for entry in day_entries: - parts.append(_day_row_html(ctx, entry)) + rows_html = "".join(_day_row_html(ctx, entry) for entry in day_entries) else: - parts.append('') + rows_html = sexp('(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))') - parts.append('
NameSlot/TimeStateCostTicketsActions
No entries yet.
') - - # Add entry button add_url = url_for( "calendars.calendar.day.calendar_entries.add_form", calendar_slug=cal_slug, day=day, month=month, year=year, ) - parts.append( - f'
' - f'
' + + return sexp( + '(section :id "day-entries" :class lc' + ' (table :class "w-full text-sm border table-fixed"' + ' (thead :class "bg-stone-100"' + ' (tr' + ' (th :class "p-2 text-left w-2/6" "Name")' + ' (th :class "text-left p-2 w-1/6" "Slot/Time")' + ' (th :class "text-left p-2 w-1/6" "State")' + ' (th :class "text-left p-2 w-1/6" "Cost")' + ' (th :class "text-left p-2 w-1/6" "Tickets")' + ' (th :class "text-left p-2 w-1/6" "Actions")))' + ' (tbody (raw! rh)))' + ' (div :id "entry-add-container" :class "mt-4"' + ' (button :type "button" :class pa' + ' :hx-get au :hx-target "#entry-add-container" :hx-swap "innerHTML"' + ' "+ Add entry")))', + lc=list_container, rh=rows_html, pa=pre_action, au=add_url, ) - parts.append('
') - return "".join(parts) def _day_row_html(ctx: dict, entry) -> str: @@ -689,11 +699,11 @@ def _day_row_html(ctx: dict, entry) -> str: ) # Name - name_html = ( - f'
' - f'' - f'{escape(entry.name)}
' + name_html = sexp( + '(td :class "p-2 align-top w-2/6" (div :class "font-medium"' + ' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"' + ' :hx-swap "outerHTML" :hx-push-url "true" n)))', + h=entry_href, pc=pill_cls, n=entry.name, ) # Slot/Time @@ -701,47 +711,55 @@ def _day_row_html(ctx: dict, entry) -> str: if slot: slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id) time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" - time_end = f" → {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" - slot_html = ( - f'
' - f'' - f'{escape(slot.name)}' - f'({time_start}{time_end})' - f'
' + time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" + slot_html = sexp( + '(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"' + ' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"' + ' :hx-swap "outerHTML" :hx-push-url "true" sn)' + ' (span :class "text-stone-600 font-normal" (str "(" ts te ")"))))', + h=slot_href, pc=pill_cls, sn=slot.name, ts=time_start, te=time_end, ) else: start = entry.start_at.strftime("%H:%M") if entry.start_at else "" - end = f" → {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - slot_html = f'
{start}{end}
' + end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" + slot_html = sexp( + '(td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str s e)))', + s=start, e=end, + ) # State state = getattr(entry, "state", "pending") or "pending" - state_html = _entry_state_badge_html(state) - state_td = f'
{state_html}
' + state_badge = _entry_state_badge_html(state) + state_td = sexp( + '(td :class "p-2 align-top w-1/6" (div :id sid (raw! sb)))', + sid=f"entry-state-{entry.id}", sb=state_badge, + ) # Cost cost = getattr(entry, "cost", None) - cost_str = f"£{cost:.2f}" if cost is not None else "£0.00" - cost_td = f'{cost_str}' + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + cost_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" c))', c=cost_str) # Tickets tp = getattr(entry, "ticket_price", None) if tp is not None: tc = getattr(entry, "ticket_count", None) tc_str = f"{tc} tickets" if tc is not None else "Unlimited" - tickets_td = ( - f'
' - f'
£{tp:.2f}
' - f'
{tc_str}
' + tickets_td = sexp( + '(td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1"' + ' (div :class "font-medium text-green-600" tp)' + ' (div :class "text-stone-600" tc)))', + tp=f"\u00a3{tp:.2f}", tc=tc_str, ) else: - tickets_td = 'No tickets' + tickets_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))') - # Actions (entry options) - keep simple, just link to entry - actions_td = f'' + actions_td = sexp('(td :class "p-2 align-top w-1/6")') - return f'{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td}' + return sexp( + '(tr :class tc (raw! nh) (raw! sh) (raw! std) (raw! ctd) (raw! ttd) (raw! atd))', + tc=tr_cls, nh=name_html, sh=slot_html, std=state_td, ctd=cost_td, ttd=tickets_td, atd=actions_td, + ) def _entry_state_badge_html(state: str) -> str: @@ -755,7 +773,10 @@ def _entry_state_badge_html(state: str) -> str: } cls = state_classes.get(state, "bg-stone-100 text-stone-700") label = state.replace("_", " ").capitalize() - return f'{label}' + return sexp( + '(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)', + c=cls, l=label, + ) # --------------------------------------------------------------------------- @@ -764,7 +785,7 @@ def _entry_state_badge_html(state: str) -> str: def _day_admin_main_panel_html(ctx: dict) -> str: """Render day admin panel (placeholder nav).""" - return '
Admin options
' + return sexp('(div :class "p-4 text-sm text-stone-500" "Admin options")') # --------------------------------------------------------------------------- @@ -786,42 +807,40 @@ def _calendar_admin_main_panel_html(ctx: dict) -> str: desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug) description_html = _calendar_description_display_html(calendar, desc_edit_url) - parts = ['
'] - parts.append('

Calendar configuration

') - parts.append('
') - parts.append(f'
') - parts.append(description_html) - parts.append('
') - - # Hidden form for direct PUT - parts.append( - f'
' - f'' - '
' - f'
{escape(desc)}
' - f'' - '
' + return sexp( + '(section :class "max-w-3xl mx-auto p-4 space-y-10"' + ' (div' + ' (h2 :class "text-xl font-semibold" "Calendar configuration")' + ' (div :id "cal-put-errors" :class "mt-2 text-sm text-red-600")' + ' (div (label :class "block text-sm font-medium text-stone-700" "Description")' + ' (raw! dh))' + ' (form :id "calendar-form" :method "post" :hx-target "#main-panel" :hx-select "#main-panel"' + """ :hx-on::before-request "document.querySelector('#cal-put-errors').textContent='';" """ + """ :hx-on::response-error "document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;" """ + """ :hx-on::after-request "if (event.detail.successful) this.reset()" """ + ' :class "hidden space-y-4 mt-4" :autocomplete "off"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div (label :class "block text-sm font-medium text-stone-700" "Description")' + ' (div d)' + ' (textarea :name "description" :autocomplete "off" :rows "4" :class "w-full p-2 border rounded" d))' + ' (div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save"))))' + ' (hr :class "border-stone-200"))', + dh=description_html, csrf=csrf, d=desc, ) - parts.append('

') - return "".join(parts) def _calendar_description_display_html(calendar, edit_url: str) -> str: """Render calendar description display with edit button.""" desc = getattr(calendar, "description", "") or "" - if desc: - desc_html = f'

{escape(desc)}

' - else: - desc_html = '

No description yet.

' - return ( - f'
{desc_html}' - f'
' + return sexp( + '(div :id "calendar-description"' + ' (if d' + ' (p :class "text-stone-700 whitespace-pre-line break-all" d)' + ' (p :class "text-stone-400 italic" "No description yet."))' + ' (button :type "button" :class "mt-2 text-xs underline"' + ' :hx-get eu :hx-target "#calendar-description" :hx-swap "outerHTML"' + ' (i :class "fas fa-edit")))', + d=desc, eu=edit_url, ) @@ -839,27 +858,33 @@ def _markets_main_panel_html(ctx: dict) -> str: csrf_token = ctx.get("csrf_token") csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") markets = ctx.get("markets") or [] - post = ctx.get("post") or {} - parts = ['
'] + form_html = "" if can_create: create_url = url_for("markets.create_market") - parts.append( - '
' - f'
""" - f'' - '
' - '
' - '
' + form_html = sexp( + '(<>' + ' (div :id "market-create-errors" :class "mt-2 text-sm text-red-600")' + ' (form :class "mt-4 flex gap-2 items-end" :hx-post cu' + ' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"' + """ :hx-on::before-request "document.querySelector('#market-create-errors').textContent='';" """ + """ :hx-on::response-error "document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;" """ + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div :class "flex-1"' + ' (label :class "block text-sm text-gray-600" "Name")' + ' (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"' + ' :placeholder "e.g. Farm Shop, Bakery"))' + ' (button :type "submit" :class "border rounded px-3 py-2" "Add market")))', + cu=create_url, csrf=csrf, ) - parts.append('
') - parts.append(_markets_list_html(ctx, markets)) - parts.append('
') - return "".join(parts) + + list_html = _markets_list_html(ctx, markets) + return sexp( + '(section :class "p-4"' + ' (raw! fh)' + ' (div :id "markets-list" :class "mt-6" (raw! lh)))', + fh=form_html, lh=list_html, + ) def _markets_list_html(ctx: dict, markets: list) -> str: @@ -871,7 +896,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str: slug = post.get("slug", "") if not markets: - return '

No markets yet. Create one above.

' + return sexp('(p :class "text-gray-500 mt-4" "No markets yet. Create one above.")') parts = [] for m in markets: @@ -879,21 +904,24 @@ def _markets_list_html(ctx: dict, markets: list) -> str: m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") del_url = url_for("markets.delete_market", market_slug=m_slug) - parts.append( - f'
' - f'' - f'

{escape(m_name)}

' - f'

/{escape(m_slug)}/

' - f'
' - ) + csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + parts.append(sexp( + '(div :class "mt-6 border rounded-lg p-4"' + ' (div :class "flex items-center justify-between gap-3"' + ' (a :class "flex items-baseline gap-3" :href h' + ' (h3 :class "font-semibold" mn)' + ' (h4 :class "text-gray-500" (str "/" ms "/")))' + ' (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"' + ' :data-confirm true :data-confirm-title "Delete market?"' + ' :data-confirm-text "Products will be hidden (soft delete)"' + ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"' + ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' + ' :hx-delete du :hx-trigger "confirmed"' + ' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"' + ' :hx-headers ch' + ' (i :class "fa-solid fa-trash"))))', + h=market_href, mn=m_name, ms=m_slug, du=del_url, ch=csrf_hdr, + )) return "".join(parts) @@ -912,30 +940,29 @@ def _payments_main_panel_html(ctx: dict) -> str: update_url = url_for("payments.update_sumup") placeholder = "--------" if sumup_configured else "sup_sk_..." - key_note = '

Key is set. Leave blank to keep current key.

' if sumup_configured else "" - connected = ('' - ' Connected') if sumup_configured else "" + input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500" - return ( - '
' - '
' - '

' - ' SumUp Payment

' - '

Configure per-page SumUp credentials. Leave blank to use the global merchant account.

' - f'
' - f'' - '
' - f'
' - '
' - f'' - f'{key_note}
' - '
' - f'
' - '{connected}
' + return sexp( + '(section :class "p-4 max-w-lg mx-auto"' + ' (div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"' + ' (h3 :class "text-lg font-semibold text-stone-800"' + ' (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")' + ' (p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")' + ' (form :hx-put uu :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"' + ' (input :type "hidden" :name "csrf_token" :value csrf)' + ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")' + ' (input :type "text" :name "merchant_code" :value mc :placeholder "e.g. ME4J6100" :class ic))' + ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")' + ' (input :type "password" :name "api_key" :value "" :placeholder ph :class ic)' + ' (when sc (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))' + ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")' + ' (input :type "text" :name "checkout_prefix" :value cp :placeholder "e.g. ROSE-" :class ic))' + ' (button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"' + ' "Save SumUp Settings")' + ' (when sc (span :class "ml-2 text-xs text-green-600"' + ' (i :class "fa fa-check-circle") " Connected")))))', + uu=update_url, csrf=csrf, mc=merchant_code, ph=placeholder, + ic=input_cls, sc=sumup_configured, cp=checkout_prefix, ) @@ -953,7 +980,10 @@ def _ticket_state_badge_html(state: str) -> str: } cls = cls_map.get(state, "bg-stone-100 text-stone-700") label = (state or "").replace("_", " ").capitalize() - return f'{label}' + return sexp( + '(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)', + c=cls, l=label, + ) # ---------------------------------------------------------------------------