diff --git a/package-lock.json b/package-lock.json index feee24768..24a711455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10562,6 +10562,23 @@ } } }, + "node_modules/@vercel/functions": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vercel/functions/-/functions-1.5.2.tgz", + "integrity": "sha512-9XuynFoM/J1X+LSahgjhuAZCbZ96vm9mpXapCkSS1MX890U7zLh7n2RW/2KLNuxsXt8u8h2dOCw+Njtg+7pXgQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-web-identity": "*" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-web-identity": { + "optional": true + } + } + }, "node_modules/@vimeo/player": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/@vimeo/player/-/player-2.24.0.tgz", @@ -34221,6 +34238,7 @@ "@tanstack/react-query": "^5.59.0", "@types/js-cookie": "^3.0.6", "@vercel/analytics": "^1.3.1", + "@vercel/functions": "^1.5.2", "@vimeo/player": "^2.24.0", "accept-language-parser": "^1.5.0", "classnames": "^2.5.1", diff --git a/shared/src/types/country.ts b/shared/src/types/country.ts index 347c12674..531aebb1e 100644 --- a/shared/src/types/country.ts +++ b/shared/src/types/country.ts @@ -250,3 +250,5 @@ export const COUNTRY_CODES = [ 'ZW', // 'Zimbabwe', ] as const; export type CountryCode = (typeof COUNTRY_CODES)[number]; + +export const isValidCountryCode = (code: string): code is CountryCode => COUNTRY_CODES.includes(code as CountryCode); diff --git a/shared/src/utils/stats/ContributionStatsCalculator.ts b/shared/src/utils/stats/ContributionStatsCalculator.ts index 9f912c6b6..854a188bc 100644 --- a/shared/src/utils/stats/ContributionStatsCalculator.ts +++ b/shared/src/utils/stats/ContributionStatsCalculator.ts @@ -2,6 +2,7 @@ import _ from 'lodash'; import { DateTime } from 'luxon'; import { FirestoreAdmin } from '../../firebase/admin/FirestoreAdmin'; import { Contribution, CONTRIBUTION_FIRESTORE_PATH, StatusKey } from '../../types/contribution'; +import { CountryCode } from '../../types/country'; import { Currency } from '../../types/currency'; import { User, USER_FIRESTORE_PATH } from '../../types/user'; import { getLatestExchangeRate } from '../exchangeRates'; @@ -30,7 +31,7 @@ export interface ContributionStats { type ContributionStatsEntry = { userId: string; isInstitution: boolean; - country: string; + country: CountryCode; amount: number; paymentFees: number; source: string; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 7637fb9e6..9a4c9e727 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,6 +1,14 @@ -import { clsx, type ClassValue } from 'clsx'; +import { CountryCode } from '@socialincome/shared/src/types/country'; +import { WebsiteRegion } from '@socialincome/website/src/i18n'; +import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** + * We use the files from GitHub instead of the package so that donations from new countries are automatically supported. + */ +export const getFlagImageURL = (country: CountryCode | Exclude) => + `https://raw.githubusercontent.com/lipis/flag-icons/a87d8b256743c9b0df05f20de2c76a7975119045/flags/1x1/${country.toLowerCase()}.svg`; diff --git a/website/package.json b/website/package.json index 0569025fb..49de9436d 100644 --- a/website/package.json +++ b/website/package.json @@ -28,6 +28,7 @@ "@tanstack/react-query": "^5.59.0", "@types/js-cookie": "^3.0.6", "@vercel/analytics": "^1.3.1", + "@vercel/functions": "^1.5.2", "@vimeo/player": "^2.24.0", "accept-language-parser": "^1.5.0", "classnames": "^2.5.1", diff --git a/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3-cards.tsx b/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3-cards.tsx index 729e2af00..0d1314822 100644 --- a/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3-cards.tsx +++ b/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3-cards.tsx @@ -1,17 +1,12 @@ 'use client'; +import { CountryCode } from '@socialincome/shared/src/types/country'; import { Button, Card, CardContent, Typography } from '@socialincome/ui'; +import { getFlagImageURL } from '@socialincome/ui/src/lib/utils'; import { Children, PropsWithChildren, useState } from 'react'; -/** - * We use the files from GitHub instead of the package so that donations from new countries are automatically supported. - */ -const getFlagImageURL = (country: string) => { - return `https://raw.githubusercontent.com/lipis/flag-icons/a87d8b256743c9b0df05f20de2c76a7975119045/flags/1x1/${country.toLowerCase()}.svg`; -}; - type CountryCardProps = { - country: string; + country: CountryCode; translations: { country: string; total: string; diff --git a/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3.tsx b/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3.tsx index cbdeacdfb..b3bdf3afa 100644 --- a/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3.tsx +++ b/website/src/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-3.tsx @@ -1,4 +1,5 @@ import { roundAmount } from '@/app/[lang]/[region]/(website)/transparency/finances/[currency]/section-1'; +import { CountryCode } from '@socialincome/shared/src/types/country'; import { Translator } from '@socialincome/shared/src/utils/i18n'; import { Typography } from '@socialincome/ui'; import { SectionProps } from './page'; @@ -10,7 +11,7 @@ export async function Section3({ params, contributionStats }: SectionProps) { namespaces: ['countries', 'website-finances'], }); const totalContributionsByCountry = contributionStats.totalContributionsByCountry as { - country: string; + country: CountryCode; amount: number; usersCount: number; }[]; diff --git a/website/src/app/[lang]/[region]/index.ts b/website/src/app/[lang]/[region]/index.ts index c71484942..dd6576bb6 100644 --- a/website/src/app/[lang]/[region]/index.ts +++ b/website/src/app/[lang]/[region]/index.ts @@ -2,6 +2,7 @@ import { WebsiteLanguage, WebsiteRegion } from '@/i18n'; export const LANGUAGE_COOKIE = 'si_lang'; export const REGION_COOKIE = 'si_region'; +export const COUNTRY_COOKIE = 'si_country'; export const CURRENCY_COOKIE = 'si_currency'; export interface DefaultParams { diff --git a/website/src/app/api/geolocation/route.ts b/website/src/app/api/geolocation/route.ts new file mode 100644 index 000000000..daf3e0482 --- /dev/null +++ b/website/src/app/api/geolocation/route.ts @@ -0,0 +1,12 @@ +import { handleApiError } from '@/app/api/auth'; +import { Geo, geolocation } from '@vercel/functions'; + +export async function GET(request: Request) { + try { + const geo = geolocation(request); + + return Response.json({ country: geo.country } as Geo); + } catch (error: any) { + return handleApiError(error); + } +} diff --git a/website/src/components/navbar/navbar-client.tsx b/website/src/components/navbar/navbar-client.tsx index 006e5e174..307b6292c 100644 --- a/website/src/components/navbar/navbar-client.tsx +++ b/website/src/components/navbar/navbar-client.tsx @@ -1,7 +1,6 @@ 'use client'; import { DefaultParams } from '@/app/[lang]/[region]'; -import { getFlagComponentByCurrency } from '@/components/country-flags'; import { DonateIcon } from '@/components/logos/donate-icon'; import { SIAnimatedLogo } from '@/components/logos/si-animated-logo'; import { SIIcon } from '@/components/logos/si-icon'; @@ -11,8 +10,10 @@ import { useGlobalStateProvider } from '@/components/providers/global-state-prov import { WebsiteCurrency, WebsiteLanguage, WebsiteRegion } from '@/i18n'; import { Bars3Icon, CheckIcon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { Typography } from '@socialincome/ui'; +import { getFlagImageURL } from '@socialincome/ui/src/lib/utils'; import classNames from 'classnames'; import _ from 'lodash'; +import Image from 'next/image'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { twMerge } from 'tailwind-merge'; @@ -59,7 +60,8 @@ const MobileNavigation = ({ lang, region, languages, regions, currencies, naviga const [visibleSection, setVisibleSection] = useState< 'main' | 'our-work' | 'about-us' | 'transparency' | 'i18n' | null >(null); - const { language, setLanguage, setRegion, currency, setCurrency } = useI18n(); + const { country, language, setLanguage, setRegion, currency, setCurrency } = useI18n(); + const isIntRegion = region === 'int'; useEffect(() => { // Prevent scrolling when the navbar is expanded @@ -188,7 +190,6 @@ const MobileNavigation = ({ lang, region, languages, regions, currencies, naviga break; case 'main': default: - const Flag = getFlagComponentByCurrency(currency); const ourWork = navigation![0]; const aboutUs = navigation![1]; const transparency = navigation![2]; @@ -211,7 +212,17 @@ const MobileNavigation = ({ lang, region, languages, regions, currencies, naviga {translations.myProfile}
- {Flag && } + {(!isIntRegion || (isIntRegion && country)) && ( + Country flag + )} setVisibleSection('i18n')}> {currency} / {languages.find((l) => l.code === language)?.translation} @@ -251,8 +262,9 @@ const MobileNavigation = ({ lang, region, languages, regions, currencies, naviga }; const DesktopNavigation = ({ lang, region, languages, regions, currencies, navigation, translations }: NavbarProps) => { - let { currency, setCurrency, setLanguage, setRegion } = useI18n(); - const Flag = getFlagComponentByCurrency(currency); + const { country, currency, setCurrency, setLanguage, setRegion } = useI18n(); + const isIntRegion = region === 'int'; + const NavbarLink = ({ href, children, className }: { href: string; children: string; className?: string }) => ( {children} @@ -321,7 +333,17 @@ const DesktopNavigation = ({ lang, region, languages, regions, currencies, navig
- {Flag && } + {(!isIntRegion || (isIntRegion && country)) && ( + Country flag + )}{' '} {languages.find((l) => l.code === lang)?.translation}
diff --git a/website/src/components/providers/context-providers.tsx b/website/src/components/providers/context-providers.tsx index 28cdb161c..ba735ea52 100644 --- a/website/src/components/providers/context-providers.tsx +++ b/website/src/components/providers/context-providers.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CURRENCY_COOKIE, LANGUAGE_COOKIE, REGION_COOKIE } from '@/app/[lang]/[region]'; +import { COUNTRY_COOKIE, CURRENCY_COOKIE, LANGUAGE_COOKIE, REGION_COOKIE } from '@/app/[lang]/[region]'; import { ApiProvider } from '@/components/providers/api-provider'; import { GlobalStateProviderProvider } from '@/components/providers/global-state-provider'; import { FacebookTracking } from '@/components/tracking/facebook-tracking'; @@ -10,6 +10,7 @@ import { useCookieState } from '@/hooks/useCookieState'; import { WebsiteCurrency, WebsiteLanguage, WebsiteRegion } from '@/i18n'; import { initializeAnalytics } from '@firebase/analytics'; import { DEFAULT_REGION } from '@socialincome/shared/src/firebase'; +import { CountryCode } from '@socialincome/shared/src/types/country'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Analytics } from '@vercel/analytics/react'; import { ConsentSettings, ConsentStatusString, setConsent } from 'firebase/analytics'; @@ -140,6 +141,8 @@ function FirebaseSDKProviders({ children }: PropsWithChildren) { } type I18nContextType = { + country: CountryCode | undefined; + setCountry: (country: CountryCode) => void; language: WebsiteLanguage | undefined; setLanguage: (language: WebsiteLanguage) => void; region: WebsiteRegion | undefined; @@ -189,16 +192,19 @@ function I18nProvider({ children }: PropsWithChildren) { const { value: language, setCookie: setLanguage } = useCookieState(LANGUAGE_COOKIE); const { value: region, setCookie: setRegion } = useCookieState(REGION_COOKIE); const { value: currency, setCookie: setCurrency } = useCookieState(CURRENCY_COOKIE); + const { value: country, setCookie: setCountry } = useCookieState(COUNTRY_COOKIE); return ( setCountry(country, { expires: 7 }), language: language, - setLanguage: (language) => setLanguage(language, { expires: 365 }), + setLanguage: (language) => setLanguage(language, { expires: 7 }), region: region, - setRegion: (country) => setRegion(country, { expires: 365 }), + setRegion: (country) => setRegion(country, { expires: 7 }), currency: currency, - setCurrency: (currency) => setCurrency(currency, { expires: 365 }), + setCurrency: (currency) => setCurrency(currency, { expires: 7 }), }} > diff --git a/website/src/hooks/queries.ts b/website/src/hooks/queries.ts new file mode 100644 index 000000000..18bf0bff6 --- /dev/null +++ b/website/src/hooks/queries.ts @@ -0,0 +1,21 @@ +import { useApi } from '@/hooks/useApi'; +import { useQuery } from '@tanstack/react-query'; +import { Geo } from '@vercel/functions'; + +export const useGeolocation = () => { + const api = useApi(); + const { + data: geolocation, + isLoading, + error, + } = useQuery({ + queryKey: ['geolocation'], + queryFn: async () => { + const response = await api.get('/api/geolocation'); + return (await response.json()) as Geo; + }, + staleTime: Infinity, + }); + + return { geolocation, isLoading, error }; +}; diff --git a/website/src/middleware.ts b/website/src/middleware.ts index 9a25faa8a..ec0b82fae 100644 --- a/website/src/middleware.ts +++ b/website/src/middleware.ts @@ -1,6 +1,6 @@ -import { CURRENCY_COOKIE } from '@/app/[lang]/[region]'; +import { COUNTRY_COOKIE, CURRENCY_COOKIE } from '@/app/[lang]/[region]'; import { WebsiteLanguage, WebsiteRegion, allWebsiteLanguages, findBestLocale, websiteRegions } from '@/i18n'; -import { CountryCode } from '@socialincome/shared/src/types/country'; +import { CountryCode, isValidCountryCode } from '@socialincome/shared/src/types/country'; import { NextRequest, NextResponse } from 'next/server'; import { bestGuessCurrency, isValidCurrency } from '../../shared/src/types/currency'; @@ -11,17 +11,39 @@ export const config = { ], }; -export const currencyMiddleware = (request: NextRequest, response: NextResponse) => { - // Checks if a valid currency is set as a cookie, and sets one with the best guess if not. +/** + * Checks if a valid country is set as a cookie, and sets one based on the request header if available. + */ +const countryMiddleware = (request: NextRequest, response: NextResponse) => { + if (request.cookies.has(COUNTRY_COOKIE) && isValidCountryCode(request.cookies.get(COUNTRY_COOKIE)?.value!)) + return response; + + const requestCountry = request.geo?.country; + if (requestCountry) + response.cookies.set({ + name: COUNTRY_COOKIE, + value: requestCountry as CountryCode, + path: '/', + maxAge: 60 * 60 * 24 * 7, + }); // 1 week + return response; +}; + +/** + * Checks if a valid currency is set as a cookie, and sets one based on the country cookie if available. + */ +const currencyMiddleware = (request: NextRequest, response: NextResponse) => { if (request.cookies.has(CURRENCY_COOKIE) && isValidCurrency(request.cookies.get(CURRENCY_COOKIE)?.value)) return response; // We use the country code from the request header if available. If not, we use the region/country from the url path. - const requestCountry = request.geo?.country || request.nextUrl.pathname.split('/').at(2)?.toUpperCase(); - const currency = bestGuessCurrency(requestCountry as CountryCode); - response.cookies.set({ name: CURRENCY_COOKIE, value: currency, path: '/', maxAge: 60 * 60 * 24 * 365 }); // 1 year + const country = request.cookies.get(CURRENCY_COOKIE)?.value as CountryCode | undefined; + const currency = bestGuessCurrency(country); + + response.cookies.set({ name: CURRENCY_COOKIE, value: currency, path: '/', maxAge: 60 * 60 * 24 * 7 }); // 1 week return response; }; -export const redirectMiddleware = (request: NextRequest) => { + +const redirectMiddleware = (request: NextRequest) => { switch (request.nextUrl.pathname) { case '/twint': return NextResponse.redirect('https://donate.raisenow.io/dpbdp'); @@ -53,7 +75,7 @@ export const redirectMiddleware = (request: NextRequest) => { } }; -export const i18nRedirectMiddleware = (request: NextRequest) => { +const i18nRedirectMiddleware = (request: NextRequest) => { // Checks if the language and country in the URL are supported, and redirects to the best locale if not. const segments = request.nextUrl.pathname.split('/'); const detectedLanguage = segments.at(1) ?? ''; @@ -82,7 +104,9 @@ export function middleware(request: NextRequest) { let response = redirectMiddleware(request) || i18nRedirectMiddleware(request); if (response) return response; + // If no redirect was triggered, we continue with the country and currency middleware. response = NextResponse.next(); + response = countryMiddleware(request, response); response = currencyMiddleware(request, response); return response; }