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:
parent
623bc075eb
commit
b94670f138
19
AGENTS.md
Normal file
19
AGENTS.md
Normal 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`)
|
||||
225
main.go
225
main.go
@ -17,8 +17,11 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// menuItem represents one option in the server-provided menu.
|
||||
// Expected JSON (one line): [{"id":"latte","name":"Caffè Latte"}, ...]
|
||||
type menuItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
// order represents the payload we submit back to the server.
|
||||
|
||||
@ -30,10 +33,12 @@ type (
|
||||
err error
|
||||
}
|
||||
orderSubmittedMsg struct {
|
||||
ack string
|
||||
err error
|
||||
ack string
|
||||
total float64
|
||||
err error
|
||||
}
|
||||
statusMsg string
|
||||
broadcastMsg string
|
||||
statusMsg string
|
||||
)
|
||||
|
||||
type FormFields struct {
|
||||
@ -45,18 +50,16 @@ type FormFields struct {
|
||||
|
||||
// model holds the TUI state.
|
||||
type model struct {
|
||||
// connection
|
||||
host string
|
||||
conn net.Conn
|
||||
|
||||
// UI
|
||||
title string
|
||||
status string
|
||||
loading bool
|
||||
err error
|
||||
lastOrder *order
|
||||
title string
|
||||
status string
|
||||
loading bool
|
||||
err error
|
||||
lastOrder *order
|
||||
broadcasts []string
|
||||
|
||||
// form
|
||||
form *huh.Form
|
||||
formFields *FormFields
|
||||
menu []menuItem
|
||||
@ -64,6 +67,9 @@ type model struct {
|
||||
itemID string
|
||||
quantityStr string
|
||||
confirm bool
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// initialModel creates a base model.
|
||||
@ -136,7 +142,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case connectedMsg:
|
||||
m.conn = msg.conn
|
||||
m.status = fmt.Sprintf("Connected to %s", m.host)
|
||||
return m, nil
|
||||
return m, listenForBroadcastsCmd(m.conn)
|
||||
|
||||
case menuLoadedMsg:
|
||||
m.loading = false
|
||||
@ -160,13 +166,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, 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)
|
||||
} else {
|
||||
m.status = "Order submitted."
|
||||
}
|
||||
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:
|
||||
m.status = string(msg)
|
||||
return m, nil
|
||||
@ -202,28 +218,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
// No dynamic layout needed, handled in View.
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
// Basic centered title and instructions
|
||||
w := lipgloss.NewStyle().Width(80)
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Render(m.title)
|
||||
host := lipgloss.NewStyle().Faint(true).Render(m.host)
|
||||
func (m model) renderHeader() string {
|
||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
|
||||
hostStyle := lipgloss.NewStyle().Faint(true)
|
||||
|
||||
lines := []string{
|
||||
w.Align(lipgloss.Center).Render(title),
|
||||
w.Align(lipgloss.Center).Render(host),
|
||||
"",
|
||||
"Controls:",
|
||||
"- n: New order",
|
||||
"- r: Reconnect",
|
||||
"- q: Quit",
|
||||
"",
|
||||
}
|
||||
title := titleStyle.Render(m.title)
|
||||
host := hostStyle.Render(m.host)
|
||||
|
||||
header := lipgloss.JoinVertical(lipgloss.Center, title, host)
|
||||
return lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(header)
|
||||
}
|
||||
|
||||
func (m model) renderLeftColumn() string {
|
||||
lines := []string{}
|
||||
|
||||
if m.loading {
|
||||
lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading..."))
|
||||
@ -236,9 +250,8 @@ func (m model) View() string {
|
||||
}
|
||||
|
||||
if m.lastOrder != nil {
|
||||
lines = append(lines, "", "Last order:")
|
||||
lines = append(lines, fmt.Sprintf("- Name: %s", m.lastOrder.Name))
|
||||
// Map selected item label for display
|
||||
lines = append(lines, "", lipgloss.NewStyle().Bold(true).Render("Last Order:"))
|
||||
lines = append(lines, fmt.Sprintf(" Name: %s", m.lastOrder.Name))
|
||||
var label string
|
||||
for _, it := range m.menu {
|
||||
if it.ID == m.lastOrder.ItemID {
|
||||
@ -247,27 +260,128 @@ func (m model) View() string {
|
||||
}
|
||||
}
|
||||
if label != "" {
|
||||
lines = append(lines, fmt.Sprintf("- Item: %s (%s)", label, m.lastOrder.ItemID))
|
||||
lines = append(lines, fmt.Sprintf(" Item: %s", label))
|
||||
} 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 {
|
||||
// When the form is active, render only the form (full screen)
|
||||
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.
|
||||
func (m *model) buildForm() *huh.Form {
|
||||
// Convert menu to huh options
|
||||
opts := make([]huh.Option[string], 0, len(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
|
||||
@ -406,7 +520,30 @@ func submitOrderCmd(conn net.Conn, ord order) tea.Cmd {
|
||||
if err != nil {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
server.go
21
server.go
@ -13,17 +13,10 @@ import (
|
||||
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{
|
||||
{ID: "latte", Name: "Caffè Latte"},
|
||||
{ID: "cap", Name: "Cappuccino"},
|
||||
{ID: "esp", Name: "Espresso"},
|
||||
{ID: "latte", Name: "Caffè Latte", Price: 4.50},
|
||||
{ID: "cap", Name: "Cappuccino", Price: 4.00},
|
||||
{ID: "esp", Name: "Espresso", Price: 3.00},
|
||||
}
|
||||
|
||||
// order is the structure the server expects for ORDER.
|
||||
@ -209,13 +202,13 @@ func handleConn(h *Hub, c net.Conn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Optional: broadcast to chat listeners for visibility
|
||||
total := float64(ord.Quantity) * chosen.Price
|
||||
|
||||
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.Fprintln(c, "OK")
|
||||
fmt.Fprintf(c, "OK|%.2f\n", total)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user