feat(SW-189): added translations and some minor changes

This commit is contained in:
Erik Tiekstra
2024-09-11 14:40:48 +02:00
parent 789133af11
commit 21d8a5835a
32 changed files with 271 additions and 151 deletions

View File

@@ -1,6 +1,12 @@
"use client"
import { AdvancedMarker, Map, useMap } from "@vis.gl/react-google-maps"
import {
AdvancedMarker,
Map,
type MapProps,
useMap,
} from "@vis.gl/react-google-maps"
import { useState } from "react"
import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
@@ -9,28 +15,31 @@ import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Title from "@/components/TempDesignSystem/Text/Title"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import useLang from "@/hooks/useLang"
import ScandicMarker from "../Markers/Scandic"
import styles from "./dynamicMap.module.css"
import type { DynamicMapContentProps } from "@/types/components/hotelPage/map/dynamicMapContent"
export default function DynamicMapContent({
hotelName,
coordinates,
}: {
hotelName: string
coordinates: { lat: number; lng: number }
}) {
}: DynamicMapContentProps) {
const intl = useIntl()
const lang = useLang()
const { isDynamicMapOpen, closeDynamicMap } = useHotelPageStore()
const [isFullScreenSidebar, setIsFullScreenSidebar] = useState(false)
const map = useMap()
const mapOptions = {
const mapOptions: MapProps = {
defaultZoom: 15,
defaultCenter: coordinates,
disableDefaultUI: true,
clickableIcons: false,
mapId: `${hotelName}-map`,
mapId: `${hotelName}-${lang}-map`,
// As reference for future styles when adding POIs
// styles: [
// {
// featureType: "poi",
@@ -75,7 +84,7 @@ export default function DynamicMapContent({
onClick={closeDynamicMap}
>
<CloseIcon color="burgundy" width={24} height={24} />
<span>Close the map</span>
<span>{intl.formatMessage({ id: "Close the map" })}</span>
</Button>
<div className={styles.zoomButtons}>
<Button
@@ -85,7 +94,7 @@ export default function DynamicMapContent({
size="small"
className={styles.zoomButton}
onClick={zoomOut}
aria-label="Zoom in"
aria-label={intl.formatMessage({ id: "Zoom in" })}
>
<MinusIcon color="burgundy" width={20} height={20} />
</Button>
@@ -96,7 +105,7 @@ export default function DynamicMapContent({
size="small"
className={styles.zoomButton}
onClick={zoomIn}
aria-label="Zoom in"
aria-label={intl.formatMessage({ id: "Zoom out" })}
>
<PlusIcon color="burgundy" width={20} height={20} />
</Button>
@@ -111,12 +120,17 @@ export default function DynamicMapContent({
className={styles.toggleButton}
onClick={toggleFullScreenSidebar}
>
{isFullScreenSidebar ? "View as map" : "View as list"}
{intl.formatMessage({
id: isFullScreenSidebar ? "View as map" : "View as list",
})}
</button>
</div>
<div className={styles.sidebarContent}>
<Title as="h4" level="h2" textTransform="regular">
Things nearby {hotelName}
{intl.formatMessage(
{ id: "Things nearby HOTEL_NAME" },
{ hotelName }
)}
</Title>
<Divider color="subtle" />
</div>

View File

@@ -7,6 +7,7 @@
left: 0;
z-index: var(--dialog-z-index);
display: flex;
background-color: var(--Base-Surface-Primary-light-Normal);
}
.sidebar {
@@ -44,7 +45,7 @@
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
font-weight: var(--typography-Body-Bold-fontWeight);
color: var(--UI-Text-Medium-contrast);
width: 100%;
}
@@ -62,7 +63,7 @@
.sidebarContent {
display: grid;
gap: var(--Spacing-x3);
align-items: start;
align-content: start;
padding: var(--Spacing-x3) var(--Spacing-x2);
height: 100%;
overflow-y: auto;
@@ -112,18 +113,13 @@
}
.zoomButton {
width: 40px;
width: var(--Spacing-x5);
height: var(--Spacing-x5);
padding: 0;
pointer-events: initial;
box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1);
}
/* @media screen and (max-width: 767px) {
.sidebar:not(.fullscreen) .sidebarContent {
display: none;
}
} */
@media screen and (min-width: 768px) {
.dynamicMap {
top: var(--main-menu-desktop-height);

View File

@@ -1,6 +1,8 @@
"use client"
import { APIProvider } from "@vis.gl/react-google-maps"
import { useEffect, useRef, useState } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
@@ -10,16 +12,17 @@ import DynamicMapContent from "./Content"
import styles from "./dynamicMap.module.css"
import type { DynamicMapProps } from "@/types/components/hotelPage/map/dynamicMap"
export default function DynamicMap({
apiKey,
hotelName,
coordinates,
}: {
apiKey: string
hotelName: string
coordinates: { lat: number; lng: number }
}) {
}: DynamicMapProps) {
const intl = useIntl()
const { isDynamicMapOpen, closeDynamicMap } = useHotelPageStore()
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
const hasMounted = useRef(false)
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && isDynamicMapOpen) {
@@ -27,12 +30,33 @@ export default function DynamicMap({
}
})
// Making sure the map is always opened at the top of the page, just below the header.
// When closing, the page should scroll back to the position it was before opening the map.
useEffect(() => {
// Skip the first render
if (!hasMounted.current) {
hasMounted.current = true
return
}
if (isDynamicMapOpen && scrollHeightWhenOpened === 0) {
setScrollHeightWhenOpened(window.scrollY)
window.scrollTo({ top: 0, behavior: "instant" })
} else if (!isDynamicMapOpen && scrollHeightWhenOpened !== 0) {
window.scrollTo({ top: scrollHeightWhenOpened, behavior: "instant" })
setScrollHeightWhenOpened(0)
}
}, [isDynamicMapOpen, scrollHeightWhenOpened])
return (
<APIProvider apiKey={apiKey}>
<Modal isOpen={isDynamicMapOpen}>
<Dialog
className={styles.dynamicMap}
aria-label={`Things nearby ${hotelName}`}
aria-label={intl.formatMessage(
{ id: "Things nearby HOTEL_NAME" },
{ hotelName }
)}
>
<DynamicMapContent hotelName={hotelName} coordinates={coordinates} />
</Dialog>

View File

@@ -1,5 +1,7 @@
"use client"
import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
import Button from "@/components/TempDesignSystem/Button"
@@ -8,9 +10,10 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./mapCard.module.css"
import { MapCardProps } from "@/types/components/hotelPage/mapCard"
import type { MapCardProps } from "@/types/components/hotelPage/map/mapCard"
export default function MapCard({ hotelName }: MapCardProps) {
const intl = useIntl()
const { openDynamicMap } = useHotelPageStore()
return (
@@ -20,7 +23,7 @@ export default function MapCard({ hotelName }: MapCardProps) {
textTransform="uppercase"
textAlign="center"
>
Nearby
{intl.formatMessage({ id: "Nearby" })}
</Caption>
<Title
color="burgundy"
@@ -39,7 +42,7 @@ export default function MapCard({ hotelName }: MapCardProps) {
className={styles.ctaButton}
onClick={openDynamicMap}
>
Explore nearby
{intl.formatMessage({ id: "Explore nearby" })}
</Button>
</div>
)

View File

@@ -1,12 +1,15 @@
"use client"
import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
import { HouseIcon, LocationIcon } from "@/components/Icons"
import { HouseIcon, MapIcon } from "@/components/Icons"
import styles from "./mobileToggle.module.css"
export default function MobileMapToggle() {
const intl = useIntl()
const { isDynamicMapOpen, openDynamicMap, closeDynamicMap } =
useHotelPageStore()
@@ -23,20 +26,20 @@ export default function MobileMapToggle() {
height={24}
width={24}
/>
<span>Hotel</span>
<span>{intl.formatMessage({ id: "Hotel" })}</span>
</button>
<button
type="button"
className={`${styles.button} ${isDynamicMapOpen ? styles.active : ""}`}
onClick={openDynamicMap}
>
<LocationIcon
<MapIcon
className={styles.icon}
color={isDynamicMapOpen ? "white" : "red"}
height={24}
width={24}
/>
<span>Nearby</span>
<span>{intl.formatMessage({ id: "Map" })}</span>
</button>
</div>
)

View File

@@ -19,15 +19,15 @@
align-items: center;
justify-content: center;
gap: var(--Spacing-x-half);
padding: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x2);
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;
font-family: var(--typography-Caption-Bold-Desktop-fontFamily);
font-size: var(--typography-Caption-Bold-Desktop-fontSize);
font-weight: var(--typography-Caption-Bold-Desktop-fontWeight);
}
.button:hover {
background-color: var(--Base-Surface-Primary-light-Hover);

View File

@@ -1,30 +0,0 @@
# Google Map Static API
### About
The Google Maps Static API lets you embed a Google Maps image on your web page. The Google Maps Static API service creates your map based on URL parameters sent through a standard HTTP request and returns the map as an image you can display on your web page. Due to regulations from Google we are not allowed to store and serve copies of images generated using the Google Maps Static API. All web pages that require static images must link the src attribute of an HTML img tag or as a background-image using CSS.
### API Key Restrictions
You can restrict your API key on websites, IP addresses, Android apps and iOS apps. For now (12/8-24) the API key is restricted by IP address, hence the request will only work at Scandic HQ network. This will be changed to a referrer website in the future.
[Read more about API key restrictions](https://developers.google.com/maps/api-security-best-practices#restricting-api-keys)
### Digital Signature
Requests exceeding 25,000 requests per day require an API key and a digital signature. However, it is strongly recommended by Google to use both an API key and digital signature, regardless of the usage.
The digital signature is set on the Google Maps Platform. For dynamically generated requests, the signature is handled through server side signing appending the signature as base64 on the request url.
[Read more about digital signature](https://developers.google.com/maps/documentation/maps-static/digital-signature)
### Generating new API keys
Regenerating an API key creates a new key that has all the old key's restrictions. This process also starts a 24-hour timer after which the old API key is deleted. During this time window, both the old and new key are accepted, giving you a chance to migrate your apps to use the new key. However, after this time period elapses, any apps still using the old API key stop working.
**Caution:** Only regenerate an API key if you absolutely must to avoid unauthorized use. This process can shut down legitimate traffic and prevent your apps from functioning properly.
[Read more about regenerating keys](https://developers.google.com/maps/api-security-best-practices#regenerate-apikey)
### Version history
| Description | Version | Date | Author |
| ------------------- | ------- | ------- | ---------------- |
| Create ReadMe file. | 1.0.0 | 12/8-24 | Fredrik Thorsson |

View File

@@ -1,15 +1,14 @@
/* eslint-disable @next/next/no-img-element */
import { env } from "@/env/server"
import StaticMapComp from "@/components/Maps/StaticMap"
import { getIntl } from "@/i18n"
import { calculateLatWithOffset } from "@/utils/map"
import ScandicMarker from "../Markers/Scandic"
import { calculateLatWithOffset, getUrlWithSignature } from "./util"
import styles from "./staticMap.module.css"
import { StaticMapProps } from "@/types/components/hotelPage/staticMap"
import type { StaticMapProps } from "@/types/components/hotelPage/map/staticMap"
export default async function StaticMap({
coordinates,
@@ -17,34 +16,23 @@ export default async function StaticMap({
zoomLevel = 14,
}: 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 markerHeight = 100
const mapLatitudeInPx = mapHeight * 0.2
const size = `380x${mapHeight}`
const mapLatitude = calculateLatWithOffset(lat, mapLatitudeInPx, 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}&center=${mapLatitude},${lng}&size=${size}&key=${key}`
)
// const url = new URL(
// `${baseUrl}?zoom=${zoomLevel}&center=${mapLatitude},${lng}&size=${size}&markers=${marker}&key=${key}`
// )
const src = getUrlWithSignature(url, secret)
const mapCoordinates = {
lat: calculateLatWithOffset(coordinates.lat, mapLatitudeInPx, zoomLevel),
lng: coordinates.lng,
}
return (
<div className={styles.staticMap}>
<img src={src} alt={alt} />
<StaticMapComp
coordinates={mapCoordinates}
width={380}
height={mapHeight}
zoomLevel={zoomLevel}
altText={intl.formatMessage({ id: "Map of HOTEL_NAME" }, { hotelName })}
/>
<ScandicMarker
className={styles.mapMarker}
height={markerHeight}

View File

@@ -1,88 +0,0 @@
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}`
}

View File

@@ -40,7 +40,7 @@
}
.mainSection {
grid-area: mainSection;
padding: var(--Spacing-x6) 0;
padding: var(--Spacing-x6) var(--Spacing-x4) 0;
}
.mapContainer {
display: flex;

View File

@@ -1,15 +1,15 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import AmenitiesList from "./AmenitiesList"
import Facilities from "./Facilities"
import { MOCK_FACILITIES } from "./Facilities/mockData"
import { setActivityCard } from "./Facilities/utils"
import IntroSection from "./IntroSection"
import DynamicMap from "./Map/DynamicMap"
import MapCard from "./Map/MapCard"
import MobileMapToggle from "./Map/MobileMapToggle"
import StaticMap from "./Map/StaticMap"
import AmenitiesList from "./AmenitiesList"
import Facilities from "./Facilities"
import IntroSection from "./IntroSection"
import PreviewImages from "./PreviewImages"
import { Rooms } from "./Rooms"
import SidePeeks from "./SidePeeks"
@@ -69,7 +69,6 @@ export default async function HotelPage() {
{googleMapsApiKey ? (
<>
<aside className={styles.mapContainer}>
{/* <Map coordinates={coordinates} hotelName={hotelName} /> */}
<StaticMap coordinates={coordinates} hotelName={hotelName} />
<MapCard hotelName={hotelName} />
</aside>

View File

@@ -1,5 +1,6 @@
"use client"
import { useEffect } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
@@ -38,6 +39,13 @@ export default function MobileMenu({
}
})
// Making sure the menu is always opened at the top of the page, just below the header.
useEffect(() => {
if (isHamburgerMenuOpen) {
window.scrollTo({ top: 0, behavior: "instant" })
}
}, [isHamburgerMenuOpen])
return (
<>
<button

View File

@@ -1,5 +1,6 @@
"use client"
import { useEffect } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
@@ -31,6 +32,13 @@ export default function MyPagesMobileMenu({
}
})
// Making sure the menu is always opened at the top of the page, just below the header.
useEffect(() => {
if (isMyPagesMobileMenuOpen) {
window.scrollTo({ top: 0, behavior: "instant" })
}
}, [isMyPagesMobileMenuOpen])
return (
<div className={styles.myPagesMobileMenu}>
<MainMenuButton

36
components/Icons/Map.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function MapIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 25 25"
fill="none"
{...props}
>
<mask
id="mask0_4031_935"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="25"
height="25"
>
<rect x="0.5" y="0.194336" width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_4031_935)">
<path
d="M15.5 21.1943L9.5 19.0943L4.85 20.8943C4.51667 21.0277 4.20833 20.9902 3.925 20.7818C3.64167 20.5735 3.5 20.2943 3.5 19.9443V5.94434C3.5 5.72767 3.5625 5.536 3.6875 5.36934C3.8125 5.20267 3.98333 5.07767 4.2 4.99434L9.5 3.19434L15.5 5.29434L20.15 3.49434C20.4833 3.361 20.7917 3.3985 21.075 3.60684C21.3583 3.81517 21.5 4.09434 21.5 4.44434V18.4443C21.5 18.661 21.4375 18.8527 21.3125 19.0193C21.1875 19.186 21.0167 19.311 20.8 19.3943L15.5 21.1943ZM14.5 18.7443V7.04434L10.5 5.64434V17.3443L14.5 18.7443ZM16.5 18.7443L19.5 17.7443V5.89434L16.5 7.04434V18.7443ZM5.5 18.4943L8.5 17.3443V5.64434L5.5 6.64434V18.4943Z"
fill="white"
/>
</g>
</svg>
)
}

View File

@@ -33,6 +33,7 @@ import {
InfoCircleIcon,
LocationIcon,
LockIcon,
MapIcon,
MinusIcon,
ParkingIcon,
People2Icon,
@@ -116,6 +117,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return LocationIcon
case IconName.Lock:
return LockIcon
case IconName.Map:
return MapIcon
case IconName.Minus:
return MinusIcon
case IconName.Parking:

View File

@@ -29,6 +29,7 @@ export { default as ImageIcon } from "./Image"
export { default as InfoCircleIcon } from "./InfoCircle"
export { default as LocationIcon } from "./Location"
export { default as LockIcon } from "./Lock"
export { default as MapIcon } from "./Map"
export { default as MinusIcon } from "./Minus"
export { default as ParkingIcon } from "./Parking"
export { default as People2Icon } from "./People2"

View File

@@ -1,46 +1,33 @@
/* eslint-disable @next/next/no-img-element */
import crypto from "node:crypto"
import { env } from "@/env/server"
import { StaticMapProps } from "@/types/components/maps/staticMap/staticMap"
import { getUrlWithSignature } from "@/utils/map"
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")
}
import { StaticMapProps } from "@/types/components/maps/staticMap"
export default function StaticMap({
city,
coordinates,
width,
height,
zoomLevel,
mapType,
zoomLevel = 14,
mapType = "roadmap",
altText,
}: StaticMapProps) {
const key = env.GOOGLE_STATIC_MAP_KEY
const secret = env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET
const safeSecret = decodeBase64Hash(removeWebSafe(secret ?? ""))
const baseUrl = "https://maps.googleapis.com/maps/api/staticmap"
const center = coordinates ? `${coordinates.lat},${coordinates.lng}` : city
if (!center) {
return null
}
// Google Maps Static API only supports images smaller than 640x640px. Read: https://developers.google.com/maps/documentation/maps-static/start#Largerimagesizes
const url = new URL(
`https://maps.googleapis.com/maps/api/staticmap?center=${city}&zoom=${zoomLevel}&size=${width}x${height}&maptype=${mapType}&key=${key}`
`${baseUrl}?center=${center}&zoom=${zoomLevel}&size=${width}x${height}&maptype=${mapType}&key=${key}`
)
const src = getUrlWithSignature(url, secret)
const hashedSignature = makeWebSafe(
encodeBase64Hash(safeSecret, url.pathname + url.search)
)
const src = url.toString() + "&signature=" + hashedSignature
return <img src={src} alt={`Map of ${city} city center`} />
return <img src={src} alt={altText} />
}