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 7c0c8ac63..9a4c9e727 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,3 +1,5 @@ +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'; @@ -8,5 +10,5 @@ export function cn(...inputs: ClassValue[]) { /** * We use the files from GitHub instead of the package so that donations from new countries are automatically supported. */ -export const getFlagImageURL = (country: string) => +export const getFlagImageURL = (country: CountryCode | Exclude) => `https://raw.githubusercontent.com/lipis/flag-icons/a87d8b256743c9b0df05f20de2c76a7975119045/flags/1x1/${country.toLowerCase()}.svg`; 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 a2c9288e2..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,11 +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'; 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 b01cf57d1..dd6576bb6 100644 --- a/website/src/app/[lang]/[region]/index.ts +++ b/website/src/app/[lang]/[region]/index.ts @@ -2,10 +2,10 @@ 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 { - country?: string; lang: WebsiteLanguage; region: WebsiteRegion; } diff --git a/website/src/components/navbar/navbar-client.tsx b/website/src/components/navbar/navbar-client.tsx index e163c93a0..307b6292c 100644 --- a/website/src/components/navbar/navbar-client.tsx +++ b/website/src/components/navbar/navbar-client.tsx @@ -7,7 +7,6 @@ import { SIIcon } from '@/components/logos/si-icon'; import { SILogo } from '@/components/logos/si-logo'; import { useI18n } from '@/components/providers/context-providers'; import { useGlobalStateProvider } from '@/components/providers/global-state-provider'; -import { useGeolocation } from '@/hooks/queries'; import { WebsiteCurrency, WebsiteLanguage, WebsiteRegion } from '@/i18n'; import { Bars3Icon, CheckIcon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { Typography } from '@socialincome/ui'; @@ -57,21 +56,12 @@ type NavbarProps = { sections?: NavigationSection[]; } & DefaultParams; -const MobileNavigation = ({ - lang, - country, - region, - languages, - regions, - currencies, - navigation, - translations, -}: NavbarProps) => { - const isIntRegion = region === 'int'; +const MobileNavigation = ({ lang, region, languages, regions, currencies, navigation, translations }: NavbarProps) => { 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 @@ -222,15 +212,15 @@ const MobileNavigation = ({ {translations.myProfile}
- {region && country && ( + {(!isIntRegion || (isIntRegion && country)) && ( )} setVisibleSection('i18n')}> @@ -271,18 +261,10 @@ const MobileNavigation = ({ ); }; -const DesktopNavigation = ({ - lang, - country, - region, - languages, - regions, - currencies, - navigation, - translations, -}: NavbarProps) => { +const DesktopNavigation = ({ lang, region, languages, regions, currencies, navigation, translations }: NavbarProps) => { + const { country, currency, setCurrency, setLanguage, setRegion } = useI18n(); const isIntRegion = region === 'int'; - let { currency, setCurrency, setLanguage, setRegion } = useI18n(); + const NavbarLink = ({ href, children, className }: { href: string; children: string; className?: string }) => ( {children} @@ -351,17 +333,17 @@ const DesktopNavigation = ({
- {region && country && ( + {(!isIntRegion || (isIntRegion && country)) && ( - )} + )}{' '} {languages.find((l) => l.code === lang)?.translation}
@@ -420,12 +402,11 @@ const DesktopNavigation = ({ export function NavbarClient(props: NavbarProps) { const { backgroundColor } = useGlobalStateProvider(); - const { geolocation } = useGeolocation(); return ( ); } 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/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; }