use grabcli_core::error::{Error, Result};
use grabcli_core::money::Money;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct Modifier {
pub id: String,
pub name: String,
pub price: Money,
pub available: bool,
pub is_default: bool,
}
#[derive(Debug, Clone)]
pub struct ModifierGroup {
pub id: String,
pub name: String,
pub available: bool,
pub selection_type: i64,
pub min: i64,
pub max: i64,
pub modifiers: Vec<Modifier>,
}
impl ModifierGroup {
pub fn required(&self) -> bool {
self.min > 0
}
}
#[derive(Debug, Clone)]
pub struct Item {
pub id: String,
pub name: String,
pub category: String,
pub price: Money,
pub available: bool,
pub description: String,
pub image_url: Option<String>,
pub modifier_groups: Vec<ModifierGroup>,
}
#[derive(Debug, Clone)]
pub struct Offer {
pub title: String,
pub subtitle: String,
pub redemption_uuid: Option<String>,
pub offer_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Menu {
pub merchant_id: String,
pub name: String,
pub section: String,
pub open: bool,
pub hours: String,
pub eta_min: Option<i64>,
pub is_integrated: bool,
pub items: Vec<Item>,
pub offers: Vec<Offer>,
}
fn money_of(v: &Value, key: &str) -> Money {
Money(v.get(key).and_then(Value::as_i64).unwrap_or(0))
}
fn str_of(v: &Value, key: &str) -> String {
v.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn parse_modifier(v: &Value) -> Modifier {
Modifier {
id: str_of(v, "ID"),
name: str_of(v, "name"),
price: money_of(v, "priceInMinorUnit"),
available: v.get("available").and_then(Value::as_bool).unwrap_or(true),
is_default: v.get("isDefault").and_then(Value::as_bool).unwrap_or(false),
}
}
fn parse_group(v: &Value) -> ModifierGroup {
ModifierGroup {
id: str_of(v, "ID"),
name: str_of(v, "name"),
available: v.get("available").and_then(Value::as_bool).unwrap_or(true),
selection_type: v.get("selectionType").and_then(Value::as_i64).unwrap_or(0),
min: v
.get("selectionRangeMin")
.and_then(Value::as_i64)
.unwrap_or(0),
max: v
.get("selectionRangeMax")
.and_then(Value::as_i64)
.unwrap_or(1),
modifiers: v
.get("modifiers")
.and_then(Value::as_array)
.map(|a| a.iter().map(parse_modifier).collect())
.unwrap_or_default(),
}
}
impl Menu {
pub fn parse(root: &Value) -> Result<Self> {
let m = root
.get("merchant")
.ok_or_else(|| Error::parse("menu response has no `merchant`"))?;
let hours = m.get("openingHours");
let mut items = Vec::new();
let mut seen = std::collections::HashSet::new();
let cats = m
.pointer("/menu/categories")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for cat in &cats {
let cat_name = str_of(cat, "name");
for it in cat
.get("items")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let id = str_of(it, "ID");
if id.is_empty() || !seen.insert(id.clone()) {
continue;
}
let groups = it
.get("modifierGroups")
.and_then(Value::as_array)
.map(|a| a.iter().map(parse_group).collect())
.unwrap_or_default();
items.push(Item {
id,
name: str_of(it, "name"),
category: cat_name.clone(),
price: money_of(it, "priceInMinorUnit"),
available: it.get("available").and_then(Value::as_bool).unwrap_or(true),
description: str_of(it, "description"),
image_url: it
.get("imgHref")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(ToString::to_string),
modifier_groups: groups,
});
}
}
let offers = m
.pointer("/offerCarousel/offerHighlights")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(parse_offer).collect())
.unwrap_or_default();
Ok(Menu {
merchant_id: str_of(m, "ID"),
name: str_of(m, "name"),
section: str_of(m, "section"),
open: hours
.and_then(|h| h.get("open"))
.and_then(Value::as_bool)
.unwrap_or(false),
hours: hours
.map(|h| str_of(h, "displayedHours"))
.unwrap_or_default(),
eta_min: m.get("ETA").and_then(Value::as_i64),
is_integrated: m
.get("isIntegrated")
.and_then(Value::as_bool)
.unwrap_or(true),
items,
offers,
})
}
pub fn item(&self, id: &str) -> Option<&Item> {
self.items.iter().find(|i| i.id == id)
}
}
fn parse_offer(v: &Value) -> Option<Offer> {
let h = v.get("highlight")?;
let meta: Value = v
.get("meta")
.and_then(Value::as_str)
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
Some(Offer {
title: str_of(h, "title"),
subtitle: str_of(h, "subtitle"),
redemption_uuid: meta
.get("redemptionUUID")
.and_then(Value::as_str)
.map(ToString::to_string),
offer_id: meta
.get("offerID")
.and_then(Value::as_str)
.map(ToString::to_string),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> Value {
serde_json::json!({
"merchant": {
"ID": "2-EXAMPLE123",
"name": "Example Noodles - Downtown",
"section": "SEC-EXAMPLE",
"isIntegrated": true,
"ETA": 40,
"openingHours": {"open": true, "displayedHours": "11:00-22:00"},
"menu": {"categories": [
{"name": "Bowls", "items": [
{"ID": "ITEM-1", "name": "Karaage Bowl", "priceInMinorUnit": 36000, "available": true,
"modifierGroups": [
{"ID": "GRP-SPICE", "name": "Spice level", "selectionType": 0,
"selectionRangeMin": 1, "selectionRangeMax": 1,
"modifiers": [
{"ID": "MOD-MILD", "name": "Mild", "priceInMinorUnit": 0, "isDefault": true},
{"ID": "MOD-HOT", "name": "Hot", "priceInMinorUnit": 0}
]}
]}
]}
]},
"offerCarousel": {"offerHighlights": [
{"highlight": {"title": "₱49 off Delivery", "subtitle": "Min spend ₱390"},
"meta": "{\"redemptionUUID\":\"999\"}"}
]}
}
})
}
#[test]
fn parses_items_modifiers_offers() -> Result<()> {
let menu = Menu::parse(&fixture())?;
assert_eq!(menu.name, "Example Noodles - Downtown");
assert_eq!(menu.section, "SEC-EXAMPLE");
assert!(menu.open);
let item = menu.item("ITEM-1").ok_or_else(|| Error::parse("item"))?;
assert_eq!(item.price, Money(36000));
let grp = &item.modifier_groups[0];
assert!(grp.required());
assert_eq!(grp.modifiers.len(), 2);
assert!(grp.modifiers[0].is_default);
assert_eq!(menu.offers[0].redemption_uuid.as_deref(), Some("999"));
Ok(())
}
}