feat(SW-189): added static map to hotel page
This commit is contained in:
37
components/ContentType/HotelPage/Map/MapCard/index.tsx
Normal file
37
components/ContentType/HotelPage/Map/MapCard/index.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.mapCard}>
|
||||
<Caption color="burgundy" textTransform="uppercase" textAlign="center">
|
||||
Nearby
|
||||
</Caption>
|
||||
<Title
|
||||
color="burgundy"
|
||||
level="h3"
|
||||
as="h4"
|
||||
textAlign="center"
|
||||
textTransform="capitalize"
|
||||
>
|
||||
{hotelName}
|
||||
</Title>
|
||||
|
||||
<Button
|
||||
theme="base"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.ctaButton}
|
||||
>
|
||||
Explore nearby
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
51
components/ContentType/HotelPage/Map/MobileToggle/index.tsx
Normal file
51
components/ContentType/HotelPage/Map/MobileToggle/index.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.mobileToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${styles.active}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<HouseIcon
|
||||
className={styles.icon}
|
||||
color={"white"}
|
||||
height={24}
|
||||
width={24}
|
||||
/>
|
||||
<span>Hotel</span>
|
||||
</button>
|
||||
<button type="button" className={styles.button} onClick={onToggle}>
|
||||
<LocationIcon
|
||||
className={styles.icon}
|
||||
color={"red"}
|
||||
height={24}
|
||||
width={24}
|
||||
/>
|
||||
<span>Nearby</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
37
components/ContentType/HotelPage/Map/StaticMap/index.tsx
Normal file
37
components/ContentType/HotelPage/Map/StaticMap/index.tsx
Normal file
@@ -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 <img src={src} alt={alt} />
|
||||
}
|
||||
88
components/ContentType/HotelPage/Map/StaticMap/util.ts
Normal file
88
components/ContentType/HotelPage/Map/StaticMap/util.ts
Normal file
@@ -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}`
|
||||
}
|
||||
17
components/ContentType/HotelPage/Map/index.tsx
Normal file
17
components/ContentType/HotelPage/Map/index.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className={styles.desktopContent}>
|
||||
<StaticMap coordinates={coordinates} hotelName={hotelName} />
|
||||
<MapCard hotelName={hotelName} />
|
||||
</div>
|
||||
<MobileToggle />
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
components/ContentType/HotelPage/Map/map.module.css
Normal file
12
components/ContentType/HotelPage/Map/map.module.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.desktopContent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.desktopContent {
|
||||
align-self: start;
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
@@ -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 <img src={src} alt={alt} />
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
<Facilities facilities={facilities} />
|
||||
</main>
|
||||
<aside className={styles.mapContainer}>
|
||||
<StaticMap coordinates={coordinates} hotelName={hotelName} />
|
||||
<Map coordinates={coordinates} hotelName={hotelName} />
|
||||
</aside>
|
||||
<SidePeeks />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user