Skip to content

Commit

Permalink
Set cookie with location of request country (#992)
Browse files Browse the repository at this point in the history
* Set cookie with location of request country

* fix typing issue

* Prioritize region if not set to 'int'

* Fix image positioning
  • Loading branch information
mkue authored Dec 28, 2024
1 parent e0b74be commit fac497e
Show file tree
Hide file tree
Showing 9 changed files with 74 additions and 56 deletions.
2 changes: 2 additions & 0 deletions shared/src/types/country.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
3 changes: 2 additions & 1 deletion shared/src/utils/stats/ContributionStatsCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -30,7 +31,7 @@ export interface ContributionStats {
type ContributionStatsEntry = {
userId: string;
isInstitution: boolean;
country: string;
country: CountryCode;
amount: number;
paymentFees: number;
source: string;
Expand Down
4 changes: 3 additions & 1 deletion ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<WebsiteRegion, 'int'>) =>
`https://raw.githubusercontent.com/lipis/flag-icons/a87d8b256743c9b0df05f20de2c76a7975119045/flags/1x1/${country.toLowerCase()}.svg`;
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}[];
Expand Down
2 changes: 1 addition & 1 deletion website/src/app/[lang]/[region]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
57 changes: 19 additions & 38 deletions website/src/components/navbar/navbar-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -222,15 +212,15 @@ const MobileNavigation = ({
{translations.myProfile}
</NavbarLink>
<div className="flex-inline flex items-center">
{region && country && (
{(!isIntRegion || (isIntRegion && country)) && (
<Image
src={getFlagImageURL(isIntRegion ? country : region)}
className="mx-3 rounded-full"
src={getFlagImageURL(isIntRegion ? country! : region)}
width={24}
height={24}
alt=""
alt="Country flag"
priority
unoptimized
className="mx-3 rounded-full"
/>
)}
<Typography as="button" className="text-2xl font-medium" onClick={() => setVisibleSection('i18n')}>
Expand Down Expand Up @@ -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 }) => (
<Link href={href} className={twMerge('hover:text-accent text-lg', className)}>
{children}
Expand Down Expand Up @@ -351,17 +333,17 @@ const DesktopNavigation = ({
</div>
<div className="group/i18n flex h-full flex-1 shrink-0 basis-1/4 flex-col">
<div className="flex flex-row items-baseline justify-end">
{region && country && (
{(!isIntRegion || (isIntRegion && country)) && (
<Image
src={getFlagImageURL(isIntRegion ? country : region)}
width={20}
height={20}
alt=""
className="m-auto mx-2 rounded-full"
src={getFlagImageURL(isIntRegion ? country! : region)}
width={24}
height={24}
alt="Country flag"
priority
unoptimized
className="m-auto mx-2 rounded-full"
/>
)}
)}{' '}
<Typography size="lg">{languages.find((l) => l.code === lang)?.translation}</Typography>
</div>
<div className="ml-auto mt-6 hidden h-full grid-cols-1 justify-items-start gap-2 text-left opacity-0 group-hover/navbar:grid group-hover/i18n:opacity-100 lg:grid-cols-[repeat(3,auto)] lg:justify-items-end lg:gap-8">
Expand Down Expand Up @@ -420,12 +402,11 @@ const DesktopNavigation = ({

export function NavbarClient(props: NavbarProps) {
const { backgroundColor } = useGlobalStateProvider();
const { geolocation } = useGeolocation();

return (
<nav className={twMerge('theme-blue group/navbar fixed inset-x-0 top-0 z-20 flex flex-col', backgroundColor)}>
<DesktopNavigation {...props} country={geolocation?.country} />
<MobileNavigation {...props} country={geolocation?.country} />
<DesktopNavigation {...props} />
<MobileNavigation {...props} />
</nav>
);
}
14 changes: 10 additions & 4 deletions website/src/components/providers/context-providers.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -189,16 +192,19 @@ function I18nProvider({ children }: PropsWithChildren) {
const { value: language, setCookie: setLanguage } = useCookieState<WebsiteLanguage>(LANGUAGE_COOKIE);
const { value: region, setCookie: setRegion } = useCookieState<WebsiteRegion>(REGION_COOKIE);
const { value: currency, setCookie: setCurrency } = useCookieState<WebsiteCurrency>(CURRENCY_COOKIE);
const { value: country, setCookie: setCountry } = useCookieState<CountryCode>(COUNTRY_COOKIE);

return (
<I18nContext.Provider
value={{
country: country,
setCountry: (country) => 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 }),
}}
>
<Suspense fallback={null}>
Expand Down
42 changes: 33 additions & 9 deletions website/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
Expand Down Expand Up @@ -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) ?? '';
Expand Down Expand Up @@ -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;
}

0 comments on commit fac497e

Please sign in to comment.