Setting the file. One moment.
Menu View · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page
12 KB restaurants/app/components/restaurants/ MenuView.tsx
TypeScript · 257 lines · 12 KB
10
import
{ imgAttrs }
from
"../../wix/media"
;
11 import { initialSelection, itemPrice, needsSelection, ruleLabel, toggleModifier, validateSelection } from "../../wix/restaurants/menu-core" ;
12 import { orderingUnavailableReason } from "../../wix/restaurants/ordering-core" ;
13 import { zonedDateTimeLabel } from "../../wix/restaurants/time-core" ;
14 import type { MenuData, MenuItem, OrderSelection, SiteMoney } from "../../wix/restaurants/types" ;
15
16 const cta = "rounded-control bg-primary px-4 py-1.5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40" ;
17 const field = "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary" ;
18
19 export interface MenuItemSheetProps {
20 item : MenuItem ;
21 money : SiteMoney ;
22 busy : boolean ;
23 onAdd : ( quantity : number , selection : OrderSelection ) => Promise < void >;
24 onClose : () => void ;
25 }
26
27 /** The choices on a dish: variant radios, one fieldset per modifier group, quantity, note, live price. */
28 export function MenuItemSheet ({ item , money , busy , onAdd , onClose } : MenuItemSheetProps ) {
29 const [ selection , setSelection ] = useState < OrderSelection >(() => initialSelection (item));
30 const [ quantity , setQuantity ] = useState ( 1 );
31 const [ errors , setErrors ] = useState < Record < string , string >>({});
32 const [ error , setError ] = useState < string | null >( null );
33 const price = itemPrice (item, selection, quantity, money);
34
35 const submit = () => {
36 const check = validateSelection (item, selection);
37 setErrors (check.errors);
38 if ( ! check.ok) return ;
39 setError ( null );
40 onAdd (quantity, selection). then (onClose). catch (( e : unknown ) => setError (e instanceof Error ? e.message : String (e)));
41 };
42
43 return (
44 < div className = "mt-3 rounded-lg border border-border bg-secondary/40 p-4" >
45 { item.variants. length > 0 && (
46 < fieldset className = "mb-3" >
47 < legend className = "text-xs font-semibold" >Size</ legend >
48 { item.variants. map (( v ) => (
49 < label key ={ v.variantId } className = "mt-1 flex items-center justify-between gap-3 text-sm" >
50 < span className = "flex items-center gap-2" >
51 < input type = "radio" name ={ `${ item . id }-variant` } checked ={ selection.variantId === v.variantId } onChange ={ () => setSelection ({ ... selection, variantId: v.variantId }) } />
52 { v.name }
53 </ span >
54 < span className = "text-muted-foreground" > { v.price } </ span >
55 </ label >
56 )) }
57 </ fieldset >
58 ) }
59 { item.modifierGroups. map (( g ) => (
60 < fieldset key ={ g.id } className = "mb-3" >
61 < legend className = "text-xs font-semibold" >
62 { g.name } < span className = "font-normal text-muted-foreground" >· { ruleLabel (g) } </ span >
63 </ legend >
64 { g.modifiers. map (( m ) => {
65 const checked = (selection.modifiers[g.id] ?? []). includes (m.key);
66 return (
67 < label key ={ m.key } className ={ `mt-1 flex items-center justify-between gap-3 text-sm ${ m . inStock ? "" : "opacity-50"}` } >
68 < span className = "flex items-center gap-2" >
69 < input
70 type ={ g.singleSelect ? "radio" : "checkbox" }
71 name ={ `${ item . id }-${ g . id }` }
72 disabled ={! m.inStock }
73 checked ={ checked }
74 onChange ={ () => setSelection ( toggleModifier (item, selection, g.id, m.key)) }
75 />
76 { m.name }
77 {! m.inStock && < span className = "text-xs text-muted-foreground" >(sold out)</ span > }
78 </ span >
79 { m.additionalCharge && < span className = "text-muted-foreground" >+ { m.additionalCharge } </ span > }
80 </ label >
81 );
82 }) }
83 { errors[g.id] && < p className = "mt-1 text-xs text-destructive" > { errors[g.id] } </ p > }
84 </ fieldset >
85 )) }
86 { item.acceptsSpecialRequests && (
87 < label className = "mb-3 block text-sm" >
88 < span className = "text-xs font-semibold" >Special request</ span >
89 < textarea rows ={ 2 } value ={ selection.specialRequest } onChange ={ ( e ) => setSelection ({ ... selection, specialRequest: e.target.value }) } className ={ `mt-1 ${ field }` } />
90 </ label >
91 ) }
92 < div className = "flex flex-wrap items-center gap-3" >
93 < div className = "inline-flex items-center gap-3 rounded-control border border-border px-2 py-0.5" >
94 < button type = "button" aria-label = "Decrease quantity" disabled ={ quantity <= 1 } onClick ={ () => setQuantity (quantity - 1 ) } className = "px-1 text-base disabled:opacity-40" >−</ button >
95 < span className = "text-sm tabular-nums" > { quantity } </ span >
96 < button type = "button" aria-label = "Increase quantity" onClick ={ () => setQuantity (quantity + 1 ) } className = "px-1 text-base" >+</ button >
97 </ div >
98 < button type = "button" disabled ={ busy } onClick ={ submit } className ={ cta } >
99 Add to order { price.formatted ? ` · ${ price . formatted }` : "" }
100 </ button >
101 < button type = "button" onClick ={ onClose } className = "text-xs text-muted-foreground underline" >Cancel</ button >
102 </ div >
103 { error && < p className = "mt-2 text-xs text-destructive" > { error } </ p > }
104 </ div >
105 );
106 }
107
108 export interface MenuItemCardProps {
109 item : MenuItem ;
110 menuId : string ;
111 sectionId : string ;
112 money : SiteMoney ;
113 }
114
115 export function MenuItemCard ({ item , menuId , sectionId , money } : MenuItemCardProps ) {
116 const { addToOrder , ordering , orderingStatus , menuOrderable , busy } = useOrderCart ();
117 const [ open , setOpen ] = useState ( false );
118 const [ error , setError ] = useState < string | null >( null );
119 const menuOk = menuOrderable (menuId);
120 // The add control's states, from the data: hidden for a market-priced dish or a menu that takes
121 // no online orders now; "Sold out" (the dish, or a required group with nothing in stock);
122 // the operation's reason (paused / unavailable); else "Add to order".
123 const showControl = ! item.marketPrice && menuOk !== false && ordering !== null ;
124 const unavailable = orderingUnavailableReason (orderingStatus);
125 const canAdd = ordering === true && item.orderable && ! busy;
126 const label = item.soldOut ? "Sold out" : unavailable || "Add to order" ;
127 const pausedUntil =
128 orderingStatus?.status === "PAUSED_UNTIL" && orderingStatus.pausedUntilIso
129 ? zonedDateTimeLabel ( new Date (orderingStatus.pausedUntilIso), orderingStatus.timeZone)
130 : "" ;
131
132 const add = ( quantity : number , selection ?: OrderSelection ) =>
133 addToOrder (item, { menuId, sectionId }, quantity, selection). then (() => setError ( null ));
134
135 return (
136 < div id ={ `item-${ item . id }` } className = "flex scroll-mt-24 gap-4 rounded-lg border border-border p-4" >
137 { item.imageUrl && (
138 < img
139 {... imgAttrs (item.imageUrl, "6rem" ) }
140 alt ={ item.name }
141 className = "h-24 w-24 flex-shrink-0 rounded-md bg-secondary object-cover"
142 />
143 ) }
144 < div className = "min-w-0 flex-1" >
145 < div className = "flex items-baseline justify-between gap-3" >
146 < p className = "text-sm font-semibold" > { item.name } </ p >
147 < p className = "whitespace-nowrap text-sm text-foreground" >
148 { item.marketPrice ? "Market price" : (item.price ?? "" ) }
149 </ p >
150 </ div >
151 { item.labels. length > 0 && (
152 < p className = "mt-0.5 text-xs text-muted-foreground" > { item.labels. map (( l ) => l.name). join ( " · " ) } </ p >
153 ) }
154 { item.description && (
155 < p className = "mt-1 text-xs leading-relaxed text-muted-foreground" > { item.description } </ p >
156 ) }
157 { item.variants. length > 0 && (
158 < p className = "mt-1 text-xs text-muted-foreground" >
159 { item.variants. map (( v ) => `${ v . name } ${ v . price }` . trim ()). join ( " · " ) }
160 </ p >
161 ) }
162 { item.modifierGroups. length > 0 && ! open && (
163 < p className = "mt-1 text-xs text-muted-foreground" >Options: { item.modifierGroups. map (( g ) => g.name). join ( ", " ) } </ p >
164 ) }
165 < div className = "mt-2 flex flex-wrap items-center gap-3" >
166 { showControl && (
167 < button
168 type = "button"
169 disabled ={! canAdd }
170 onClick ={ () => {
171 if ( needsSelection (item)) setOpen (( o ) => ! o);
172 else add ( 1 ). catch (( e : unknown ) => setError (e instanceof Error ? e.message : String (e)));
173 } }
174 className ={ cta }
175 >
176 { label }
177 </ button >
178 ) }
179 { pausedUntil && ! item.soldOut && < p className = "text-xs text-muted-foreground" >Back at { pausedUntil } </ p > }
180 { error && < p className = "text-xs text-destructive" > { error } </ p > }
181 </ div >
182 { open && canAdd && < MenuItemSheet item ={ item } money ={ money } busy ={ busy } onAdd ={ add } onClose ={ () => setOpen ( false ) } /> }
183 </ div >
184 </ div >
185 );
186 }
187
188 export interface MenuViewProps {
189 initialMenus ?: MenuData [];
190 emptyMessage ?: string ;
191 }
192
193 const tab = ( active : boolean ) =>
194 `rounded-control border px-4 py-1.5 text-sm font-medium transition-colors ${
195 active
196 ? "border-primary bg-primary text-primary-foreground"
197 : "border-border text-foreground hover:bg-secondary"
198 }` ;
199
200 export default function MenuView ({
201 initialMenus,
202 emptyMessage = "The menu is being written — check back soon." ,
203 } : MenuViewProps) {
204 const { menus , activeMenuId , setActiveMenuId , activeMenu , error } = useMenus ({ initialMenus });
205 const { menuOrderable } = useOrderCart ();
206
207 if (menus === null ) {
208 return (
209 < div aria-busy = "true" >
210 { Array. from ({ length: 4 }, ( _ , i ) => (
211 < div key ={ i } className = "mb-4 h-24 animate-pulse rounded-lg bg-secondary" />
212 )) }
213 </ div >
214 );
215 }
216 if (menus. length === 0 ) {
217 return < p className = "py-16 text-center text-muted-foreground" > { error ?? emptyMessage } </ p >;
218 }
219
220 return (
221 < div >
222 { menus. length > 1 && (
223 < div className = "mb-8 flex flex-wrap gap-2" role = "group" aria-label = "Menus" >
224 { menus. map (( m ) => (
225 < button key ={ m.id } type = "button" className ={ tab (m.id === activeMenuId) } onClick ={ () => setActiveMenuId (m.id) } >
226 { m.name }
227 </ button >
228 )) }
229 </ div >
230 ) }
231 { activeMenu && menuOrderable (activeMenu.id) === false && (
232 < p className = "mb-6 text-sm text-muted-foreground" >This menu isn't available for online ordering right now.</ p >
233 ) }
234 { activeMenu && activeMenu.sections. length > 1 && (
235 < nav className = "mb-8 flex flex-wrap gap-x-5 gap-y-1 text-sm" aria-label = "Sections" >
236 { activeMenu.sections. map (( s ) => (
237 < a key ={ s.id } href ={ `#section-${ s . id }` } className = "text-muted-foreground no-underline transition-colors hover:text-foreground" >
238 { s.name }
239 </ a >
240 )) }
241 </ nav >
242 ) }
243 { error && < p className = "mb-4 text-sm text-destructive" > { error } </ p > }
244 { activeMenu?.sections. map (( section ) => (
245 < section key ={ section.id } id ={ `section-${ section . id }` } className = "mb-12" >
246 < h2 className = "text-lg font-semibold tracking-tight" > { section.name } </ h2 >
247 { section.description && < p className = "mt-1 text-sm text-muted-foreground" > { section.description } </ p > }
248 < div className = "mt-4 grid grid-cols-1 gap-4 md:grid-cols-2" >
249 { section.items. map (( item ) => (
250 < MenuItemCard key ={ item.id } item ={ item } menuId ={ activeMenu.id } sectionId ={ section.id } money ={ activeMenu.money } />
251 )) }
252 </ div >
253 </ section >
254 )) }
255 </ div >
256 );
257 }