feat(order-console): add pricing, recent order feed, and responsive layout; extend protocol

Client:
- Add menuItem.Price; show prices in menu and compute totals from server ack
- Track and render recent “[order] …” broadcasts in right column
- Responsive layout with header/body/footer and window size handling
- Listen for server broadcasts after connect; keep last 10 orders
- Parse ORDER ack as “OK|<total>”; display “Total: $<amount>”
- Split view rendering into header/left/right/footer helpers
- Enhance form options to include “Name - $Price”
- Add width/height state; remove fixed 80-col assumption

Server:
- Extend defaultMenu with prices
- Compute total = qty × price; broadcast “[order] … ($xx.xx)”
- Acknowledge ORDER with “OK|<total>” instead of plain “OK”

Proto/UX:
- MENU returns items with price
- ORDER response now includes total for client display
This commit is contained in:
Syahdan 2025-10-15 22:28:03 +07:00
parent 623bc075eb
commit b94670f138
3 changed files with 207 additions and 58 deletions

19
AGENTS.md Normal file
View File

@ -0,0 +1,19 @@
# Agent Guidelines for clink
## Build & Test Commands
- Build: `go build -o clink .`
- Cross-compile: `./build.sh` (creates binaries in `dist/` for all platforms)
- Run client: `go run . -host localhost:9000`
- Run server: `go run . -server -host localhost:9000`
- Test: `go test ./...` (currently no tests)
- Format: `gofmt -w .`
- Lint: `go vet ./...`
## Code Style
- **Imports**: Standard library first, then third-party (blank line between), use named imports for clarity (e.g., `tea "github.com/charmbracelet/bubbletea"`)
- **Formatting**: Use `gofmt`, tabs for indentation
- **Types**: Explicit types, struct fields exported when needed for JSON/external use
- **Naming**: CamelCase for exports, camelCase for private, descriptive names (e.g., `connectedMsg`, `fetchMenuCmd`)
- **Error handling**: Check all errors explicitly, wrap with `fmt.Errorf("context: %w", err)` for context
- **Comments**: Minimal, only for public APIs or complex logic
- **Concurrency**: Use channels and goroutines for I/O operations (see `connectCmd`, `Hub.Run`)

207
main.go
View File

@ -17,8 +17,11 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// menuItem represents one option in the server-provided menu. type menuItem struct {
// Expected JSON (one line): [{"id":"latte","name":"Caffè Latte"}, ...] ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
// order represents the payload we submit back to the server. // order represents the payload we submit back to the server.
@ -31,8 +34,10 @@ type (
} }
orderSubmittedMsg struct { orderSubmittedMsg struct {
ack string ack string
total float64
err error err error
} }
broadcastMsg string
statusMsg string statusMsg string
) )
@ -45,18 +50,16 @@ type FormFields struct {
// model holds the TUI state. // model holds the TUI state.
type model struct { type model struct {
// connection
host string host string
conn net.Conn conn net.Conn
// UI
title string title string
status string status string
loading bool loading bool
err error err error
lastOrder *order lastOrder *order
broadcasts []string
// form
form *huh.Form form *huh.Form
formFields *FormFields formFields *FormFields
menu []menuItem menu []menuItem
@ -64,6 +67,9 @@ type model struct {
itemID string itemID string
quantityStr string quantityStr string
confirm bool confirm bool
width int
height int
} }
// initialModel creates a base model. // initialModel creates a base model.
@ -136,7 +142,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case connectedMsg: case connectedMsg:
m.conn = msg.conn m.conn = msg.conn
m.status = fmt.Sprintf("Connected to %s", m.host) m.status = fmt.Sprintf("Connected to %s", m.host)
return m, nil return m, listenForBroadcastsCmd(m.conn)
case menuLoadedMsg: case menuLoadedMsg:
m.loading = false m.loading = false
@ -160,13 +166,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.err = nil m.err = nil
if msg.ack != "" { if msg.total > 0 {
m.status = fmt.Sprintf("Order submitted. Total: $%.2f", msg.total)
} else if msg.ack != "" {
m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack) m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack)
} else {
m.status = "Order submitted."
} }
return m, nil return m, nil
case broadcastMsg:
msgText := string(msg)
if strings.HasPrefix(msgText, "[order]") {
m.broadcasts = append(m.broadcasts, msgText)
if len(m.broadcasts) > 10 {
m.broadcasts = m.broadcasts[1:]
}
}
return m, listenForBroadcastsCmd(m.conn)
case statusMsg: case statusMsg:
m.status = string(msg) m.status = string(msg)
return m, nil return m, nil
@ -202,29 +218,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
// No dynamic layout needed, handled in View. m.width = msg.Width
m.height = msg.Height
} }
return m, nil return m, nil
} }
func (m model) View() string { func (m model) renderHeader() string {
// Basic centered title and instructions titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
w := lipgloss.NewStyle().Width(80) hostStyle := lipgloss.NewStyle().Faint(true)
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Render(m.title)
host := lipgloss.NewStyle().Faint(true).Render(m.host)
lines := []string{ title := titleStyle.Render(m.title)
w.Align(lipgloss.Center).Render(title), host := hostStyle.Render(m.host)
w.Align(lipgloss.Center).Render(host),
"", header := lipgloss.JoinVertical(lipgloss.Center, title, host)
"Controls:", return lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(header)
"- n: New order",
"- r: Reconnect",
"- q: Quit",
"",
} }
func (m model) renderLeftColumn() string {
lines := []string{}
if m.loading { if m.loading {
lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading...")) lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading..."))
} else if m.status != "" { } else if m.status != "" {
@ -236,9 +250,8 @@ func (m model) View() string {
} }
if m.lastOrder != nil { if m.lastOrder != nil {
lines = append(lines, "", "Last order:") lines = append(lines, "", lipgloss.NewStyle().Bold(true).Render("Last Order:"))
lines = append(lines, fmt.Sprintf("- Name: %s", m.lastOrder.Name)) lines = append(lines, fmt.Sprintf(" Name: %s", m.lastOrder.Name))
// Map selected item label for display
var label string var label string
for _, it := range m.menu { for _, it := range m.menu {
if it.ID == m.lastOrder.ItemID { if it.ID == m.lastOrder.ItemID {
@ -247,27 +260,128 @@ func (m model) View() string {
} }
} }
if label != "" { if label != "" {
lines = append(lines, fmt.Sprintf("- Item: %s (%s)", label, m.lastOrder.ItemID)) lines = append(lines, fmt.Sprintf(" Item: %s", label))
} else { } else {
lines = append(lines, fmt.Sprintf("- Item: %s", m.lastOrder.ItemID)) lines = append(lines, fmt.Sprintf(" Item: %s", m.lastOrder.ItemID))
} }
lines = append(lines, fmt.Sprintf("- Quantity: %d", m.lastOrder.Quantity)) lines = append(lines, fmt.Sprintf(" Quantity: %d", m.lastOrder.Quantity))
} }
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.NewStyle().
Width(m.width/2 - 2).
Height(m.height - 6).
Padding(1).
Render(content)
}
func (m model) renderRightColumn() string {
lines := []string{}
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
lines = append(lines, headerStyle.Render("Recent Orders:"))
lines = append(lines, "")
if len(m.broadcasts) == 0 {
lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No orders yet..."))
} else {
bulletStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141"))
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
priceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
for _, b := range m.broadcasts {
msg := strings.TrimPrefix(b, "[order] ")
parts := strings.SplitN(msg, " ordered ", 2)
if len(parts) == 2 {
customer := parts[0]
orderDetails := parts[1]
line := fmt.Sprintf("%s %s ordered %s",
bulletStyle.Render("•"),
nameStyle.Render(customer),
itemStyle.Render(orderDetails))
if idx := strings.Index(orderDetails, "($"); idx != -1 {
priceStart := idx
priceEnd := strings.Index(orderDetails[priceStart:], ")")
if priceEnd != -1 {
priceEnd += priceStart + 1
beforePrice := orderDetails[:priceStart]
priceText := orderDetails[priceStart:priceEnd]
line = fmt.Sprintf("%s %s ordered %s %s",
bulletStyle.Render("•"),
nameStyle.Render(customer),
itemStyle.Render(beforePrice),
priceStyle.Render(priceText))
}
}
lines = append(lines, line)
}
}
}
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.NewStyle().
Width(m.width/2 - 2).
Height(m.height - 6).
Padding(1).
Render(content)
}
func (m model) renderFooter() string {
connStatus := ""
if m.conn != nil {
connStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("● Connected")
} else {
connStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("● Disconnected")
}
controls := lipgloss.NewStyle().Faint(true).Render("n: New Order r: Reconnect q: Quit")
leftSide := connStatus
rightSide := controls
footer := lipgloss.JoinHorizontal(lipgloss.Top,
lipgloss.NewStyle().Width(m.width/2).Render(leftSide),
lipgloss.NewStyle().Width(m.width/2).Align(lipgloss.Right).Render(rightSide),
)
return lipgloss.NewStyle().Width(m.width).Render(footer)
}
func (m model) View() string {
if m.form != nil { if m.form != nil {
// When the form is active, render only the form (full screen)
return m.form.View() return m.form.View()
} }
return lipgloss.JoinVertical(lipgloss.Left, lines...) if m.width == 0 || m.height == 0 {
return "Loading..."
}
header := m.renderHeader()
leftCol := m.renderLeftColumn()
rightCol := m.renderRightColumn()
body := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
footer := m.renderFooter()
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
body,
"",
footer,
)
} }
// buildForm constructs the order form: Input (name) -> Select (menu) -> Input (qty) -> Confirm. // buildForm constructs the order form: Input (name) -> Select (menu) -> Input (qty) -> Confirm.
func (m *model) buildForm() *huh.Form { func (m *model) buildForm() *huh.Form {
// Convert menu to huh options
opts := make([]huh.Option[string], 0, len(m.menu)) opts := make([]huh.Option[string], 0, len(m.menu))
for _, it := range m.menu { for _, it := range m.menu {
opts = append(opts, huh.NewOption(it.Name, it.ID)) opts = append(opts, huh.NewOption(fmt.Sprintf("%s - $%.2f", it.Name, it.Price), it.ID))
} }
// Reset bound fields for a fresh form // Reset bound fields for a fresh form
@ -406,7 +520,30 @@ func submitOrderCmd(conn net.Conn, ord order) tea.Cmd {
if err != nil { if err != nil {
return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)} return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)}
} }
return orderSubmittedMsg{ack: strings.TrimRight(line, "\r\n")} line = strings.TrimRight(line, "\r\n")
parts := strings.Split(line, "|")
ack := parts[0]
var total float64
if len(parts) > 1 {
if t, err := strconv.ParseFloat(parts[1], 64); err == nil {
total = t
}
}
return orderSubmittedMsg{ack: ack, total: total}
}
}
func listenForBroadcastsCmd(conn net.Conn) tea.Cmd {
return func() tea.Msg {
if conn == nil {
return nil
}
r := bufio.NewReader(conn)
line, err := r.ReadString('\n')
if err != nil {
return statusMsg(fmt.Sprintf("Connection closed: %v", err))
}
return broadcastMsg(strings.TrimRight(line, "\r\n"))
} }
} }

View File

@ -13,17 +13,10 @@ import (
gonanoid "github.com/matoous/go-nanoid/v2" gonanoid "github.com/matoous/go-nanoid/v2"
) )
// menuItem is the structure returned to clients for MENU.
type menuItem struct {
ID string `json:"id"`
Name string `json:"name"`
}
// defaultMenu is a simple, static menu. Replace or make dynamic as needed.
var defaultMenu = []menuItem{ var defaultMenu = []menuItem{
{ID: "latte", Name: "Caffè Latte"}, {ID: "latte", Name: "Caffè Latte", Price: 4.50},
{ID: "cap", Name: "Cappuccino"}, {ID: "cap", Name: "Cappuccino", Price: 4.00},
{ID: "esp", Name: "Espresso"}, {ID: "esp", Name: "Espresso", Price: 3.00},
} }
// order is the structure the server expects for ORDER. // order is the structure the server expects for ORDER.
@ -209,13 +202,13 @@ func handleConn(h *Hub, c net.Conn) {
continue continue
} }
// Optional: broadcast to chat listeners for visibility total := float64(ord.Quantity) * chosen.Price
h.msgCh <- broadcast{ h.msgCh <- broadcast{
text: fmt.Sprintf("[order] %s (%s) ordered %d × %s", username, id, ord.Quantity, chosen.Name), text: fmt.Sprintf("[order] %s (%s) ordered %d × %s ($%.2f)", username, id, ord.Quantity, chosen.Name, total),
} }
// Acknowledge to the ordering client fmt.Fprintf(c, "OK|%.2f\n", total)
fmt.Fprintln(c, "OK")
continue continue
} }