Skip to content

Commit 2c3e3a0

Browse files
committed
feat(places): restaurant menu links + food-delivery deep-linking
Two new integrations plus the place-panel UI and core glue that consume them, following docs/plans/restaurant-menus-and-delivery.md (link menus, never rehost; deep-link delivery, no live data). restaurants: resolves a menu link from OSM website:menu/menu:url tags or a robots-respecting homepage crawl (schema.org hasMenu, multilingual whole-word menu-link heuristics, PDF detection), SSRF-guarded and TTL-cached. food-delivery: region-filtered deep-link hand-off to 15 worldwide platforms, each pre-filled with the restaurant name + delivery location; Uber Eats resolves the precise /store page (positive+negative cached), others use location-scoped links. Optional affiliate/scid config. PlaceOverviewTab gains self-hiding Menu + "Place an order" rows. Region filtering falls back to a coordinate->country lookup (new /geocode/country route) when the place carries no country. Adds shared toHttpUrl/bareDomain core helpers and an apps/api integrations type-check pass.
1 parent 1c4f278 commit 2c3e3a0

31 files changed

Lines changed: 2109 additions & 2 deletions

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"dev": "node --env-file=.env --watch --import tsx/esm src/server.ts",
1010
"build": "tsc --noEmit && node esbuild.config.mjs",
1111
"start": "node dist/server.js",
12-
"check-types": "tsc --noEmit",
12+
"check-types": "tsc --noEmit && tsc --noEmit -p tsconfig.integrations.json",
1313
"db:generate": "drizzle-kit generate",
1414
"db:push": "drizzle-kit push",
1515
"db:studio": "drizzle-kit studio",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"lib": ["ES2024", "DOM"],
5+
"types": ["node"]
6+
},
7+
"include": ["../../integrations/**/*.ts"],
8+
"exclude": [
9+
"../../integrations/**/dist/**",
10+
"../../integrations/**/node_modules/**",
11+
"../../integrations/**/*.test.ts",
12+
"../../integrations/**/__tests__/**"
13+
]
14+
}

apps/api/turbo.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://turborepo.dev/schema.json",
3+
"extends": ["//"],
4+
"tasks": {
5+
"check-types": {
6+
"dependsOn": ["^check-types"],
7+
"inputs": [
8+
"$TURBO_DEFAULT$",
9+
"$TURBO_ROOT$/integrations/**/*.ts",
10+
"$TURBO_ROOT$/integrations/**/*.json"
11+
]
12+
}
13+
}
14+
}
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"use client";
2+
3+
import CheckIcon from "@mui/icons-material/Check";
4+
import CloseIcon from "@mui/icons-material/Close";
5+
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
6+
import DeliveryDiningOutlinedIcon from "@mui/icons-material/DeliveryDiningOutlined";
7+
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
8+
import RestaurantMenuIcon from "@mui/icons-material/RestaurantMenu";
9+
import Box from "@mui/material/Box";
10+
import Dialog from "@mui/material/Dialog";
11+
import IconButton from "@mui/material/IconButton";
12+
import Tooltip from "@mui/material/Tooltip";
13+
import Typography from "@mui/material/Typography";
14+
import type { Place } from "@openmapx/core";
15+
import {
16+
bareDomain,
17+
buildDeliveryOpenUrl,
18+
type DeliveryProviderInfo,
19+
isFoodPlace,
20+
resolveOsmMenuUrl,
21+
useCountryFromCoordinates,
22+
useDeliveryProviders,
23+
useRestaurantMenu,
24+
} from "@openmapx/core";
25+
import { useTranslations } from "next-intl";
26+
import { type MouseEvent, type ReactNode, useId, useState } from "react";
27+
import { TEAL } from "@/lib/theme";
28+
import { BrandMark } from "../shared/BrandMark";
29+
30+
/**
31+
* A clickable detail row matching PlaceOverviewTab's DetailRow styling. Kept
32+
* separate from DetailRow on purpose: this whole row is a button (onClick +
33+
* keyboard), carries primary/secondary text plus arbitrary trailing actions,
34+
* and underlines on hover — folding that interaction model into DetailRow would
35+
* complicate its many static, non-clickable uses.
36+
*/
37+
function FoodRow({
38+
icon,
39+
primary,
40+
secondary,
41+
onClick,
42+
trailing,
43+
underlineOnHover,
44+
}: {
45+
icon: ReactNode;
46+
primary: ReactNode;
47+
secondary?: ReactNode;
48+
onClick: () => void;
49+
trailing?: ReactNode;
50+
underlineOnHover?: boolean;
51+
}) {
52+
return (
53+
<Box
54+
role="button"
55+
tabIndex={0}
56+
onClick={onClick}
57+
onKeyDown={(e) => {
58+
if (e.key === "Enter" || e.key === " ") {
59+
e.preventDefault();
60+
onClick();
61+
}
62+
}}
63+
sx={{
64+
display: "flex",
65+
gap: 2,
66+
alignItems: "center",
67+
py: 1.25,
68+
cursor: "pointer",
69+
mx: -2,
70+
px: 2,
71+
"&:hover": { bgcolor: "action.hover" },
72+
"& .row-trailing": { opacity: 0 },
73+
"&:hover .row-trailing": { opacity: 1 },
74+
...(underlineOnHover && {
75+
"&:hover .food-primary": { textDecoration: "underline" },
76+
}),
77+
}}
78+
>
79+
<Box sx={{ color: TEAL, flexShrink: 0, display: "flex" }}>{icon}</Box>
80+
<Box sx={{ flex: 1, minWidth: 0 }}>
81+
<Typography className="food-primary" variant="body2" sx={{ color: "text.primary" }}>
82+
{primary}
83+
</Typography>
84+
{secondary && (
85+
<Typography
86+
variant="caption"
87+
sx={{
88+
color: "text.secondary",
89+
display: "block",
90+
overflow: "hidden",
91+
textOverflow: "ellipsis",
92+
whiteSpace: "nowrap",
93+
}}
94+
>
95+
{secondary}
96+
</Typography>
97+
)}
98+
</Box>
99+
{trailing && (
100+
<Box
101+
className="row-trailing"
102+
sx={{ flexShrink: 0, display: "flex", transition: "opacity 0.15s" }}
103+
>
104+
{trailing}
105+
</Box>
106+
)}
107+
</Box>
108+
);
109+
}
110+
111+
/**
112+
* Restaurant Menu + "Place an order" rows for the place overview, mirroring
113+
* Google Maps. Both are pure hand-offs (see
114+
* docs/plans/restaurant-menus-and-delivery.md): the Menu row links to the
115+
* restaurant's own menu (OSM `website:menu` tag, else a crawl of its site); the
116+
* order row opens a region-filtered "Continue with" list of delivery platforms,
117+
* each pre-filled with the restaurant name. Self-hides when nothing applies.
118+
*/
119+
export function PlaceFoodActions({ place }: { place: Place }) {
120+
const t = useTranslations("place");
121+
const tc = useTranslations("common");
122+
const food = isFoodPlace(place);
123+
const osmMenuUrl = resolveOsmMenuUrl(place);
124+
125+
// Crawl the site only when there's no explicit OSM menu tag.
126+
const { data: crawledMenu } = useRestaurantMenu(
127+
place.website,
128+
food && !osmMenuUrl && Boolean(place.website),
129+
);
130+
// When the place carries no country, resolve one from its coordinates so the
131+
// provider list is region-filtered and the deep links target the right
132+
// country — otherwise every worldwide platform would show on an un-geocoded
133+
// POI. Only fires for food places that actually lack a country.
134+
const { data: resolvedCountry } = useCountryFromCoordinates(
135+
place.coordinates,
136+
food && !place.countryCode,
137+
);
138+
const countryCode = place.countryCode ?? resolvedCountry ?? undefined;
139+
const { data: providersData } = useDeliveryProviders(countryCode, food);
140+
141+
const [dialogOpen, setDialogOpen] = useState(false);
142+
const [copied, setCopied] = useState(false);
143+
const dialogTitleId = useId();
144+
145+
if (!food) return null;
146+
147+
const menuUrl = osmMenuUrl ?? crawledMenu?.menuUrl ?? null;
148+
const providers = providersData?.providers ?? [];
149+
150+
const openMenu = (e?: MouseEvent) => {
151+
// The whole row and the open-in-new icon both call this. Stop the icon
152+
// click from *also* bubbling to the row's onClick, which would open the
153+
// menu twice — Firefox then blocks the second window as a pop-up.
154+
e?.stopPropagation();
155+
if (menuUrl) window.open(menuUrl, "_blank", "noopener,noreferrer");
156+
};
157+
const copyMenu = (e: MouseEvent) => {
158+
e.stopPropagation();
159+
if (!menuUrl) return;
160+
navigator.clipboard.writeText(menuUrl).then(
161+
() => {
162+
setCopied(true);
163+
setTimeout(() => setCopied(false), 2000);
164+
},
165+
() => {},
166+
);
167+
};
168+
const openProvider = (provider: DeliveryProviderInfo) => {
169+
const [lng, lat] = place.coordinates;
170+
const url = buildDeliveryOpenUrl(provider.id, {
171+
name: place.name,
172+
city: place.city,
173+
countryCode,
174+
lat,
175+
lng,
176+
postcode: place.osmTags?.["addr:postcode"],
177+
address: place.address,
178+
});
179+
window.open(url, "_blank", "noopener,noreferrer");
180+
setDialogOpen(false);
181+
};
182+
183+
if (!menuUrl && providers.length === 0) return null;
184+
185+
return (
186+
<>
187+
{menuUrl && (
188+
<FoodRow
189+
icon={<RestaurantMenuIcon sx={{ fontSize: 22 }} />}
190+
primary={t("menu")}
191+
secondary={bareDomain(menuUrl)}
192+
onClick={openMenu}
193+
underlineOnHover
194+
trailing={
195+
<>
196+
<Tooltip title={t("openMenuLink")}>
197+
<IconButton
198+
size="small"
199+
onClick={openMenu}
200+
sx={{ color: "text.secondary", p: 0.5 }}
201+
>
202+
<OpenInNewIcon sx={{ fontSize: 18 }} />
203+
</IconButton>
204+
</Tooltip>
205+
<Tooltip title={copied ? tc("copied") : tc("copy")}>
206+
<IconButton
207+
size="small"
208+
onClick={copyMenu}
209+
sx={{ color: "text.secondary", p: 0.5 }}
210+
>
211+
{copied ? (
212+
<CheckIcon sx={{ fontSize: 18 }} />
213+
) : (
214+
<ContentCopyIcon sx={{ fontSize: 18 }} />
215+
)}
216+
</IconButton>
217+
</Tooltip>
218+
</>
219+
}
220+
/>
221+
)}
222+
{providers.length > 0 && (
223+
<FoodRow
224+
icon={<DeliveryDiningOutlinedIcon sx={{ fontSize: 22 }} />}
225+
primary={t("orderDelivery")}
226+
onClick={() => setDialogOpen(true)}
227+
/>
228+
)}
229+
230+
<Dialog
231+
open={dialogOpen}
232+
onClose={() => setDialogOpen(false)}
233+
maxWidth="xs"
234+
fullWidth
235+
aria-labelledby={dialogTitleId}
236+
>
237+
<Box sx={{ p: 2 }}>
238+
<Box sx={{ display: "flex", alignItems: "center", mb: 1.5 }}>
239+
<Typography id={dialogTitleId} variant="h6" sx={{ flex: 1, fontWeight: 600 }}>
240+
{t("deliveryDialogTitle")}
241+
</Typography>
242+
<IconButton size="small" onClick={() => setDialogOpen(false)} aria-label={tc("close")}>
243+
<CloseIcon sx={{ fontSize: 20 }} />
244+
</IconButton>
245+
</Box>
246+
{providers.map((provider) => (
247+
<Box
248+
key={provider.id}
249+
role="button"
250+
tabIndex={0}
251+
onClick={() => openProvider(provider)}
252+
onKeyDown={(e) => {
253+
if (e.key === "Enter" || e.key === " ") {
254+
e.preventDefault();
255+
openProvider(provider);
256+
}
257+
}}
258+
sx={{
259+
display: "flex",
260+
alignItems: "center",
261+
gap: 1.5,
262+
py: 1.25,
263+
px: 1,
264+
mx: -1,
265+
borderRadius: 1.5,
266+
cursor: "pointer",
267+
"&:hover": { bgcolor: "action.hover" },
268+
}}
269+
>
270+
<BrandMark branding={{ name: provider.name, color: provider.color }} size={28} />
271+
<Box sx={{ flex: 1, minWidth: 0 }}>
272+
<Typography variant="body2" sx={{ fontWeight: 500 }}>
273+
{provider.name}
274+
</Typography>
275+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
276+
{provider.domain}
277+
</Typography>
278+
</Box>
279+
<OpenInNewIcon sx={{ fontSize: 16, color: "text.disabled", flexShrink: 0 }} />
280+
</Box>
281+
))}
282+
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
283+
{t("deliveryNote")}
284+
</Typography>
285+
</Box>
286+
</Dialog>
287+
</>
288+
);
289+
}

apps/web/src/components/panels/place/PlaceOverviewTab.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { PlaceTransitSection } from "../transit/PlaceTransitSection";
5656
import { DataSourceSections } from "./DataSourceSections";
5757
import { PlaceActionButtons } from "./PlaceActionButtons";
5858
import { PlaceAirportInfo } from "./PlaceAirportInfo";
59+
import { PlaceFoodActions } from "./PlaceFoodActions";
5960
import { PlaceHarborFacilities } from "./PlaceHarborFacilities";
6061
import { PlaceMarineWeatherContent } from "./PlaceMarineWeather";
6162
import { PlaceSunTimes } from "./PlaceSunTimes";
@@ -518,6 +519,9 @@ export function PlaceOverviewTab({
518519
))
519520
)}
520521

522+
{/* Restaurant menu + delivery hand-off (self-hides for non-food places) */}
523+
<PlaceFoodActions place={place} />
524+
521525
{/* Phone */}
522526
{isLoading && !place.phone ? (
523527
<DetailRow icon={<PhoneIcon sx={{ fontSize: 22 }} />}>

0 commit comments

Comments
 (0)