diff --git a/components/ContentType/HotelPage/Map/MapCard/index.tsx b/components/ContentType/HotelPage/Map/MapCard/index.tsx new file mode 100644 index 000000000..3b98bde9a --- /dev/null +++ b/components/ContentType/HotelPage/Map/MapCard/index.tsx @@ -0,0 +1,37 @@ +"use client" + +import Button from "@/components/TempDesignSystem/Button" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Title from "@/components/TempDesignSystem/Text/Title" + +import styles from "./mapCard.module.css" + +import { MapCardProps } from "@/types/components/hotelPage/mapCard" + +export default function MapCard({ hotelName }: MapCardProps) { + return ( +
+ + Nearby + + + {hotelName} + + + +
+ ) +} diff --git a/components/ContentType/HotelPage/Map/MapCard/mapCard.module.css b/components/ContentType/HotelPage/Map/MapCard/mapCard.module.css new file mode 100644 index 000000000..7fb7919ec --- /dev/null +++ b/components/ContentType/HotelPage/Map/MapCard/mapCard.module.css @@ -0,0 +1,14 @@ +.mapCard { + position: absolute; + bottom: 15%; + background-color: var(--Base-Surface-Primary-light-Normal); + margin: 0 var(--Spacing-x4); + padding: var(--Spacing-x2); + box-shadow: 0 0 2.5rem 0 rgba(0, 0, 0, 0.12); + border-radius: var(--Corner-radius-Medium); + display: grid; +} + +.ctaButton { + margin-top: var(--Spacing-x2); +} diff --git a/components/ContentType/HotelPage/Map/MobileToggle/index.tsx b/components/ContentType/HotelPage/Map/MobileToggle/index.tsx new file mode 100644 index 000000000..bce32e387 --- /dev/null +++ b/components/ContentType/HotelPage/Map/MobileToggle/index.tsx @@ -0,0 +1,51 @@ +"use client" + +import { HouseIcon, LocationIcon } from "@/components/Icons" + +import styles from "./mobileToggle.module.css" + +import { IconName } from "@/types/components/icon" + +export default function MobileToggle() { + const options: { text: string; icon: IconName }[] = [ + { + text: "Hotel", + icon: IconName.House, + }, + { + text: "Nearby", + icon: IconName.Location, + }, + ] + + function onToggle() { + console.log("Toggle mobile map") + } + + return ( +
+ + +
+ ) +} diff --git a/components/ContentType/HotelPage/Map/MobileToggle/mobileToggle.module.css b/components/ContentType/HotelPage/Map/MobileToggle/mobileToggle.module.css new file mode 100644 index 000000000..b93941151 --- /dev/null +++ b/components/ContentType/HotelPage/Map/MobileToggle/mobileToggle.module.css @@ -0,0 +1,43 @@ +.mobileToggle { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--Spacing-x-half); + align-items: center; + border-radius: 4rem; + background-color: var(--Base-Surface-Primary-light-Normal); + box-shadow: 0 0 30px 2px rgba(0, 0, 0, 0.15); + padding: 0.375rem; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + gap: var(--Spacing-x-half); + padding: var(--Spacing-x1); + background-color: var(--Base-Surface-Primary-light-Normal); + border-width: 0; + cursor: pointer; + border-radius: 2.5rem; + color: var(--Base-Text-Accent); + font-family: var(--typography-Body-Bold-fontFamily); + font-size: var(--typography-Body-Bold-fontSize); + font-weight: 500; +} +.button:hover { + background-color: var(--Base-Surface-Primary-light-Hover); +} + +.button.active { + background-color: var(--Primary-Strong-Surface-Normal); + color: var(--Base-Text-Inverted); +} +.button.active:hover { + background-color: var(--Primary-Strong-Surface-Hover); +} + +@media screen and (min-width: 1367px) { + .mobileToggle { + display: none; + } +} diff --git a/components/ContentType/HotelPage/StaticMap/ReadMe.md b/components/ContentType/HotelPage/Map/StaticMap/ReadMe.md similarity index 100% rename from components/ContentType/HotelPage/StaticMap/ReadMe.md rename to components/ContentType/HotelPage/Map/StaticMap/ReadMe.md diff --git a/components/ContentType/HotelPage/Map/StaticMap/index.tsx b/components/ContentType/HotelPage/Map/StaticMap/index.tsx new file mode 100644 index 000000000..7eeeed944 --- /dev/null +++ b/components/ContentType/HotelPage/Map/StaticMap/index.tsx @@ -0,0 +1,37 @@ +/* eslint-disable @next/next/no-img-element */ + +import { env } from "@/env/server" + +import { getIntl } from "@/i18n" + +import { calculateLatWithOffset, getUrlWithSignature } from "./util" + +import { StaticMapProps } from "@/types/components/hotelPage/staticMap" + +export default async function StaticMap({ + coordinates, + hotelName, + zoomLevel = 16, +}: StaticMapProps) { + const intl = await getIntl() + const key = env.GOOGLE_STATIC_MAP_KEY + const secret = env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET + const baseUrl = "https://maps.googleapis.com/maps/api/staticmap" + const { lng, lat } = coordinates + const mapHeight = 785 + const size = `380x${mapHeight}` + const mapLatitude = calculateLatWithOffset(lat, mapHeight * 0.2, zoomLevel) + // Custom Icon should be available from a public URL accessible by Google Static Maps API. At the moment, we don't have a public URL for the custom icon. + // const marker = `icon:https://IMAGE_URL|${lat},${lng}` + const marker = `${lat},${lng}` + // Google Maps Static API only supports images smaller than 640x640px. Read: https://developers.google.com/maps/documentation/maps-static/start#Largerimagesizes + const alt = intl.formatMessage({ id: "Map of HOTEL_NAME" }, { hotelName }) + + const url = new URL( + `${baseUrl}?zoom=${zoomLevel}¢er=${mapLatitude},${lng}&size=${size}&markers=${marker}&key=${key}` + ) + + const src = getUrlWithSignature(url, secret) + + return {alt} +} diff --git a/components/ContentType/HotelPage/Map/StaticMap/util.ts b/components/ContentType/HotelPage/Map/StaticMap/util.ts new file mode 100644 index 000000000..d7c933209 --- /dev/null +++ b/components/ContentType/HotelPage/Map/StaticMap/util.ts @@ -0,0 +1,88 @@ +import crypto from "node:crypto" + +// Helper function to calculate the latitude offset +export function calculateLatWithOffset( + latitude: number, + offsetPx: number, + zoomLevel: number +): number { + const earthCircumference = 40075017 // Earth's circumference in meters + const tileSize = 256 // Height of a tile in pixels (standard in Google Maps) + + // Calculate ground resolution (meters per pixel) at the given latitude and zoom level + const groundResolution = + (earthCircumference * Math.cos((latitude * Math.PI) / 180)) / + (tileSize * Math.pow(2, zoomLevel)) + + // Calculate the number of meters for the given offset in pixels + const metersOffset = groundResolution * offsetPx + + // Convert the meters offset into a latitude offset (1 degree latitude is ~111,320 meters) + const latOffset = metersOffset / 111320 + + // Return the new latitude by subtracting the offset + return latitude - latOffset +} + +/** + * Util functions taken from https://developers.google.com/maps/documentation/maps-static/digital-signature#sample-code-for-url-signing + * Used to sign the URL for the Google Static Maps API. + */ + +/** + * Convert from 'web safe' base64 to true base64. + * + * @param {string} safeEncodedString The code you want to translate + * from a web safe form. + * @return {string} + */ +function removeWebSafe(safeEncodedString: string) { + return safeEncodedString.replace(/-/g, "+").replace(/_/g, "/") +} + +/** + * Convert from true base64 to 'web safe' base64 + * + * @param {string} encodedString The code you want to translate to a + * web safe form. + * @return {string} + */ +function makeWebSafe(encodedString: string) { + return encodedString.replace(/\+/g, "-").replace(/\//g, "_") +} + +/** + * Takes a base64 code and decodes it. + * + * @param {string} code The encoded data. + * @return {string} + */ +function decodeBase64Hash(code: string) { + return Buffer.from(code, "base64") +} + +/** + * Takes a key and signs the data with it. + * + * @param {string} key Your unique secret key. + * @param {string} data The url to sign. + * @return {string} + */ +function encodeBase64Hash(key: Buffer, data: string) { + return crypto.createHmac("sha1", key).update(data).digest("base64") +} + +/** + * Sign a URL using a secret key. + * + * @param {URL} url The url you want to sign. + * @param {string} secret Your unique secret key. + * @return {string} + */ +export function getUrlWithSignature(url: URL, secret = "") { + const path = url.pathname + url.search + const safeSecret = decodeBase64Hash(removeWebSafe(secret)) + const hashedSignature = makeWebSafe(encodeBase64Hash(safeSecret, path)) + + return `${url.toString()}&signature=${hashedSignature}` +} diff --git a/components/ContentType/HotelPage/Map/index.tsx b/components/ContentType/HotelPage/Map/index.tsx new file mode 100644 index 000000000..0b7823f90 --- /dev/null +++ b/components/ContentType/HotelPage/Map/index.tsx @@ -0,0 +1,17 @@ +import MapCard from "./MapCard" +import MobileToggle from "./MobileToggle" +import StaticMap from "./StaticMap" + +import styles from "./map.module.css" + +export default function Map({ coordinates, hotelName }: any) { + return ( + <> +
+ + +
+ + + ) +} diff --git a/components/ContentType/HotelPage/Map/map.module.css b/components/ContentType/HotelPage/Map/map.module.css new file mode 100644 index 000000000..5fd435396 --- /dev/null +++ b/components/ContentType/HotelPage/Map/map.module.css @@ -0,0 +1,12 @@ +.desktopContent { + display: none; +} + +@media screen and (min-width: 1367px) { + .desktopContent { + align-self: start; + position: relative; + display: grid; + justify-items: center; + } +} diff --git a/components/ContentType/HotelPage/StaticMap/index.tsx b/components/ContentType/HotelPage/StaticMap/index.tsx deleted file mode 100644 index 9bd942222..000000000 --- a/components/ContentType/HotelPage/StaticMap/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { env } from "@/env/server" -import { getIntl } from "@/i18n" -import { StaticMapProps } from "@/types/components/hotelPage/staticMap" -import crypto from "node:crypto" - -function removeWebSafe(safeEncodedString: string) { - return safeEncodedString.replace(/-/g, "+").replace(/_/g, "/") -} - -function makeWebSafe(encodedString: string) { - return encodedString.replace(/\+/g, "-").replace(/\//g, "_") -} - -function decodeBase64Hash(code: string) { - return Buffer.from(code, "base64") -} - -function encodeBase64Hash(key: Buffer, data: string) { - return crypto.createHmac("sha1", key).update(data).digest("base64") -} - -// Google Maps Static API only supports images smaller than 640x640px. Read: https://developers.google.com/maps/documentation/maps-static/start#Largerimagesizes -export default async function StaticMap({ - coordinates, - hotelName, - zoomLevel = 14, -}: StaticMapProps) { - const intl = await getIntl() - const key = env.GOOGLE_STATIC_MAP_KEY - const secret = env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET - const safeSecret = decodeBase64Hash(removeWebSafe(secret ?? "")) - const alt = intl.formatMessage({ id: "Map of HOTEL_NAME" }, { hotelName }) - - const url = new URL( - `https://maps.googleapis.com/maps/api/staticmap?center=${coordinates.latitude},${coordinates.longitude}&zoom=${zoomLevel}&size=380x640&key=${key}` - ) - - const hashedSignature = makeWebSafe( - encodeBase64Hash(safeSecret, url.pathname + url.search) - ) - - const src = url.toString() + "&signature=" + hashedSignature - - return {alt} -} diff --git a/components/ContentType/HotelPage/hotelPage.module.css b/components/ContentType/HotelPage/hotelPage.module.css index 78f0cd686..a594f1902 100644 --- a/components/ContentType/HotelPage/hotelPage.module.css +++ b/components/ContentType/HotelPage/hotelPage.module.css @@ -22,8 +22,12 @@ } .mapContainer { - grid-area: mapContainer; - display: none; + position: fixed; + bottom: var(--Spacing-x5); + display: flex; + justify-content: center; + width: 100vw; + z-index: 1; } .introContainer { @@ -45,7 +49,12 @@ } .mapContainer { grid-area: mapContainer; - display: block; + position: sticky; + top: 0; + align-self: start; + justify-content: initial; + width: 100%; + z-index: 0; } .pageContainer > nav { diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index b1868cb32..6d55da887 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -1,20 +1,17 @@ import { serverClient } from "@/lib/trpc/server" -<<<<<<< HEAD -import { MOCK_FACILITIES } from "./Facilities/mockData" -import { setActivityCard } from "./Facilities/utils" -======= ->>>>>>> 491f385b (fix: wrong prop name) import AmenitiesList from "./AmenitiesList" import Facilities from "./Facilities" +import { MOCK_FACILITIES } from "./Facilities/mockData" +import { setActivityCard } from "./Facilities/utils" import IntroSection from "./IntroSection" +import Map from "./Map" import PreviewImages from "./PreviewImages" import { Rooms } from "./Rooms" import SidePeeks from "./SidePeeks" import TabNavigation from "./TabNavigation" import styles from "./hotelPage.module.css" -import StaticMap from "./StaticMap" export default async function HotelPage() { const hotelData = await serverClient().hotel.get({ @@ -38,10 +35,10 @@ export default async function HotelPage() { const facilities = [...MOCK_FACILITIES] activitiesCard && facilities.push(setActivityCard(activitiesCard)) - + const coordinates = { - latitude: hotelLocation.latitude, - longitude: hotelLocation.longitude, + lat: hotelLocation.latitude, + lng: hotelLocation.longitude, } return ( @@ -65,7 +62,7 @@ export default async function HotelPage() { diff --git a/components/TempDesignSystem/Text/Caption/caption.module.css b/components/TempDesignSystem/Text/Caption/caption.module.css index b27906dbf..d359c2a36 100644 --- a/components/TempDesignSystem/Text/Caption/caption.module.css +++ b/components/TempDesignSystem/Text/Caption/caption.module.css @@ -16,6 +16,16 @@ text-decoration: var(--typography-Caption-Bold-textDecoration); } +.uppercase { + font-family: var(--typography-Caption-Bold-fontFamily); + font-size: var(--typography-Caption-Bold-fontSize); + font-weight: var(--typography-Caption-Bold-fontWeight); + letter-spacing: var(--typography-Caption-Bold-letterSpacing); + line-height: var(--typography-Caption-Bold-lineHeight); + text-decoration: var(--typography-Caption-Bold-textDecoration); + text-transform: uppercase; +} + .regular { font-family: var(--typography-Caption-Regular-fontFamily); font-size: var(--typography-Caption-Regular-fontSize); diff --git a/components/TempDesignSystem/Text/Caption/variants.ts b/components/TempDesignSystem/Text/Caption/variants.ts index f1dbafa37..b8a3ef6b1 100644 --- a/components/TempDesignSystem/Text/Caption/variants.ts +++ b/components/TempDesignSystem/Text/Caption/variants.ts @@ -15,6 +15,7 @@ const config = { textTransform: { bold: styles.bold, regular: styles.regular, + uppercase: styles.uppercase, }, textAlign: { center: styles.center, @@ -34,6 +35,7 @@ const fontOnlyConfig = { textTransform: { bold: styles.bold, regular: styles.regular, + uppercase: styles.uppercase, }, }, defaultVariants: { diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index fc8186ab8..a8b36f3f9 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -98,6 +98,7 @@ "Log in/Join": "Log på/Tilmeld dig", "Log out": "Log ud", "Manage preferences": "Administrer præferencer", + "Map of HOTEL_NAME": "Map of {hotelName}", "Meetings & Conferences": "Møder & Konferencer", "Member price": "Medlemspris", "Member price from": "Medlemspris fra", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 9092608b6..f6b45875c 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -96,6 +96,7 @@ "Log in/Join": "Log in/Anmelden", "Log out": "Ausloggen", "Manage preferences": "Verwalten von Voreinstellungen", + "Map of HOTEL_NAME": "Map of {hotelName}", "Member price": "Mitgliederpreis", "Member price from": "Mitgliederpreis ab", "Members": "Mitglieder", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index d08f7fd8b..30038d804 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -102,6 +102,7 @@ "Log in/Join": "Log in/Join", "Log out": "Log out", "Manage preferences": "Manage preferences", + "Map of HOTEL_NAME": "Map of {hotelName}", "Meetings & Conferences": "Meetings & Conferences", "Member price": "Member price", "Member price from": "Member price from", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index b1d91abe3..11a670427 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -97,6 +97,7 @@ "Log in/Join": "Kirjaudu sisään/Liittyä", "Log out": "Kirjaudu ulos", "Manage preferences": "Asetusten hallinta", + "Map of HOTEL_NAME": "Map of {hotelName}", "Meetings & Conferences": "Kokoukset & Konferenssit", "Member price": "Jäsenhinta", "Member price from": "Jäsenhinta alkaen", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 9d66f5df8..fd5a16bab 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -97,6 +97,7 @@ "Log in/Join": "Logg på/Bli med", "Log out": "Logg ut", "Manage preferences": "Administrer preferanser", + "Map of HOTEL_NAME": "Map of {hotelName}", "Meetings & Conferences": "Møter & Konferanser", "Member price": "Medlemspris", "Member price from": "Medlemspris fra", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index be1e56601..13bd7f748 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -99,6 +99,7 @@ "Log in/Join": "Logga in/Gå med", "Log out": "Logga ut", "Manage preferences": "Hantera inställningar", + "Map of HOTEL_NAME": "Map of {hotelName}", "Meetings & Conferences": "Möten & Konferenser", "Member price": "Medlemspris", "Member price from": "Medlemspris från", diff --git a/public/_static/icons/map-marker-pin.svg b/public/_static/icons/map-marker-pin.svg new file mode 100644 index 000000000..93bcc1732 --- /dev/null +++ b/public/_static/icons/map-marker-pin.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index 7f059aefa..192bab55c 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -283,7 +283,7 @@ const parkingPricingSchema = z.object({ const parkingSchema = z.object({ type: z.string(), name: z.string(), - address: z.string(), + address: z.string().optional(), numberOfParkingSpots: z.number().optional(), numberOfChargingSpaces: z.number().optional(), distanceToHotel: z.number(), diff --git a/types/components/hotelPage/mapCard.ts b/types/components/hotelPage/mapCard.ts new file mode 100644 index 000000000..0ea8678e0 --- /dev/null +++ b/types/components/hotelPage/mapCard.ts @@ -0,0 +1,3 @@ +export interface MapCardProps { + hotelName: string +} diff --git a/types/components/hotelPage/staticMap.ts b/types/components/hotelPage/staticMap.ts index b6694830b..7eb2a2a77 100644 --- a/types/components/hotelPage/staticMap.ts +++ b/types/components/hotelPage/staticMap.ts @@ -1,6 +1,6 @@ type Coordinates = { - latitude: number - longitude: number + lat: number + lng: number } export type StaticMapProps = {