diff --git a/README.md b/README.md index 12d029a..08b53c3 100644 --- a/README.md +++ b/README.md @@ -122,4 +122,7 @@ We are using two scopes: 1. OAuth ID, which is used to assign ownership to the application specific data when it's is stored and retrieved from our database. 2. user's e-mail address, which is stored in our database so that we can contact our users in case such action is needed. -For any questions regarding the use of the Google API service please feel free to reach out at support@rezije.app \ No newline at end of file +For any questions regarding the use of the Google API service please feel free to reach out at support@rezije.app + +# Localization +Localization was done by following video: https://www.youtube.com/watch?v=uZQ5d2bRMO4 \ No newline at end of file diff --git a/app/bill/[id]/add/not-found.tsx b/app/[locale]/bill/[id]/add/not-found.tsx similarity index 100% rename from app/bill/[id]/add/not-found.tsx rename to app/[locale]/bill/[id]/add/not-found.tsx diff --git a/app/bill/[id]/add/page.tsx b/app/[locale]/bill/[id]/add/page.tsx similarity index 100% rename from app/bill/[id]/add/page.tsx rename to app/[locale]/bill/[id]/add/page.tsx diff --git a/app/bill/[id]/delete/not-found.tsx b/app/[locale]/bill/[id]/delete/not-found.tsx similarity index 100% rename from app/bill/[id]/delete/not-found.tsx rename to app/[locale]/bill/[id]/delete/not-found.tsx diff --git a/app/bill/[id]/delete/page.tsx b/app/[locale]/bill/[id]/delete/page.tsx similarity index 100% rename from app/bill/[id]/delete/page.tsx rename to app/[locale]/bill/[id]/delete/page.tsx diff --git a/app/bill/[id]/edit/not-found.tsx b/app/[locale]/bill/[id]/edit/not-found.tsx similarity index 100% rename from app/bill/[id]/edit/not-found.tsx rename to app/[locale]/bill/[id]/edit/not-found.tsx diff --git a/app/bill/[id]/edit/page.tsx b/app/[locale]/bill/[id]/edit/page.tsx similarity index 100% rename from app/bill/[id]/edit/page.tsx rename to app/[locale]/bill/[id]/edit/page.tsx diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..0cdb144 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,49 @@ +import '@/app/ui/global.css'; +import { inter } from '@/app/ui/fonts'; +import { Metadata } from 'next'; + +export const metadata:Metadata = { + alternates: { + canonical: 'https://rezije.app', + languages: { + 'en': 'https://rezije.app/en/', + 'hr': 'https://rezije.app/hr/', + } + }, + openGraph: { + title: 'Režije', + description: 'Preuzmite kontrolu nad svojim režijama', + url: 'https://rezije.app', + siteName: 'Režije', + images: [ + { + url: 'https://rezije.app/opengraph-image.png', // Must be an absolute URL + width: 432, + height: 466, + alt: "Režije - Preuzmite kontrolu nad svojim režijama" + }, + { + url: 'https://rezije.app/icon6.png', // Must be an absolute URL + width: 256, + height: 256, + alt: "Režije - Preuzmite kontrolu nad svojim režijama" + }, + ], + locale: 'hr', + type: 'website', + }, +} + +export default function RootLayout({ + children, + params: { locale }, +}: { + children: React.ReactNode; + params: { locale:string }; +}) { + return ( + + {children} + + ); +} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx new file mode 100644 index 0000000..d5cd131 --- /dev/null +++ b/app/[locale]/login/page.tsx @@ -0,0 +1,82 @@ +import { FC } from 'react'; +import { Main } from '@/app/ui/Main'; + +import { authConfig } from "@/app/lib/auth"; +import { SignInButton } from '@/app/ui/SignInButton'; +import Image from 'next/image'; +import { getTranslations } from "next-intl/server"; + +type Provider = { + id: string; + name: string; + type: string; + style: { + logo: string; + bg: string; + text: string; + }; +}; + +function getProviders(): Provider[] { + const providerKeys: (keyof Provider)[] = ["id", "name", "type", "style"]; + return authConfig.providers.map((provider) => + getKeyValuesFromObject(provider, providerKeys) + ); +} + +function getKeyValuesFromObject(obj: any, keys: (keyof T)[]): T { + return keys.reduce((acc, key) => { + if (obj[key]) { + acc[key] = obj[key]; + } + return acc; + }, {} as T); +} + +const Page:FC = async () => { + + const providers = await getProviders(); + const t = await getTranslations("login-page"); + + return ( +
+

+ {t("main-card.title-1")} + {t("main-card.title-2")} + {t("main-card.title-3")} +

+

{t("main-card.text-1")}

+

{t("main-card.text-2")}

+ + { + Object.values(providers).map((provider) => ( +
+ +
+ )) + } +
+ + +

{t("card-1.title")}

+

{t("card-1.text")}

+ + +

{t("card-2.title")}

+

{t("card-2.text")}

+ Boje označavaju status računa + +

{t("card-3.title")}

+

{t("card-3.text")}

+ +
+ ); +} + +export default Page; \ No newline at end of file diff --git a/app/page.tsx b/app/[locale]/page.tsx similarity index 77% rename from app/page.tsx rename to app/[locale]/page.tsx index 6505d33..92efeca 100644 --- a/app/page.tsx +++ b/app/[locale]/page.tsx @@ -1,7 +1,7 @@ import { FC, Suspense } from 'react'; -import { Main } from './ui/Main'; -import HomePage from './ui/HomePage'; -import { MonthCardSkeleton } from './ui/MonthCardSkeleton'; +import { Main } from '@/app/ui/Main'; +import HomePage from '@/app/ui/HomePage'; +import { MonthCardSkeleton } from '@/app/ui/MonthCardSkeleton'; export interface PageProps { searchParams?: { diff --git a/app/policy/page.tsx b/app/[locale]/policy/page.tsx similarity index 96% rename from app/policy/page.tsx rename to app/[locale]/policy/page.tsx index ac61ac6..4f01985 100644 --- a/app/policy/page.tsx +++ b/app/[locale]/policy/page.tsx @@ -1,6 +1,4 @@ -import { Main } from "../ui/Main"; -import { PageFooter } from "../ui/PageFooter"; -import { PageHeader } from "../ui/PageHeader"; +import { Main } from "@/app/ui/Main"; const ConsentPage = () =>
diff --git a/app/terms/page.tsx b/app/[locale]/terms/page.tsx similarity index 99% rename from app/terms/page.tsx rename to app/[locale]/terms/page.tsx index 761231e..41cde7a 100644 --- a/app/terms/page.tsx +++ b/app/[locale]/terms/page.tsx @@ -1,4 +1,4 @@ -import { Main } from "../ui/Main"; +import { Main } from "@/app/ui/Main"; const TermsPage = () =>
diff --git a/app/year-month/[id]/add/page.tsx b/app/[locale]/year-month/[id]/add/page.tsx similarity index 100% rename from app/year-month/[id]/add/page.tsx rename to app/[locale]/year-month/[id]/add/page.tsx diff --git a/app/i18n.ts b/app/i18n.ts new file mode 100644 index 0000000..fc0e549 --- /dev/null +++ b/app/i18n.ts @@ -0,0 +1,30 @@ +import {notFound} from 'next/navigation'; +import {getRequestConfig} from 'next-intl/server'; +import { Formats, TranslationValues } from 'next-intl'; + +// Can be imported from a shared config +export const locales = ['en', 'hr']; + +export const localeNames:Record = { + en: 'English', + hr: 'Hrvatski' +}; + +export const defaultLocale = 'en'; + +/** Templating function type as returned by `useTemplate` and `getTranslations` */ +export type IntlTemplateFn = + // this function type if returned by `useTransations` + ((key: TargetKey, values?: TranslationValues | undefined, formats?: Partial | undefined) => string) | + // this functon type if returned by `getTranslations` + ((key: [TargetKey] extends [never] ? string : TargetKey, values?: TranslationValues | undefined, formats?: Partial | undefined) => string); + + +export default getRequestConfig(async ({locale}) => { + // Validate that the incoming `locale` parameter is valid + if (!locales.includes(locale as any)) notFound(); + + return { + messages: (await import(`../messages/${locale}.json`)).default + }; +}); \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index bc3a4c8..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import '@/app/ui/global.css'; -import { inter } from '@/app/ui/fonts'; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index 5370e4b..0db48ab 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -7,6 +7,8 @@ import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; import { gotoHome } from './navigationActions'; +import { getTranslations } from "next-intl/server"; +import { IntlTemplateFn } from '@/app/i18n'; export type State = { errors?: { @@ -18,9 +20,13 @@ export type State = { message?:string | null; } -const FormSchema = z.object({ +/** + * Schema for validating bill form fields + * @description this is defined as factory function so that it can be used with the next-intl library +*/ +const FormSchema = (t:IntlTemplateFn) => z.object({ _id: z.string(), - billName: z.coerce.string().min(1, "Bill Name is required."), + billName: z.coerce.string().min(1, t("bill-name-required")), billNotes: z.string(), payedAmount: z.string().nullable().transform((val, ctx) => { @@ -33,7 +39,7 @@ const FormSchema = z.object({ if (isNaN(parsed)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Not a number", + message: t("not-a-number"), }); // This is a special symbol you can use to @@ -46,7 +52,7 @@ const FormSchema = z.object({ if (parsed < 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Value must be a positive number", + message: t("negative-number") }); // This is a special symbol you can use to @@ -61,10 +67,6 @@ const FormSchema = z.object({ }), }); - parseFloat - -const UpdateBill = FormSchema.omit({ _id: true }); - /** * converts the file to a format stored in the database * @param billAttachment @@ -113,18 +115,23 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI const { id: userId } = user; - const validatedFields = UpdateBill.safeParse({ - billName: formData.get('billName'), - billNotes: formData.get('billNotes'), - payedAmount: formData.get('payedAmount'), - }); + const t = await getTranslations("bill-edit-form.validation"); + + // FormSchema + const validatedFields = FormSchema(t) + .omit({ _id: true }) + .safeParse({ + billName: formData.get('billName'), + billNotes: formData.get('billNotes'), + payedAmount: formData.get('payedAmount'), + }); // If form validation fails, return errors early. Otherwise, continue... if(!validatedFields.success) { console.log("updateBill.validation-error"); return({ errors: validatedFields.error.flatten().fieldErrors, - message: "Missing Fields. Field to Update Bill.", + message: t("form-error-message"), }); } diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index d90d792..635acfb 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -8,7 +8,8 @@ import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; import { gotoHome } from './navigationActions'; import { unstable_noStore as noStore } from 'next/cache'; -import { asyncTimeout } from '../asyncTimeout'; +import { IntlTemplateFn } from '@/app/i18n'; +import { getTranslations } from "next-intl/server"; export type State = { errors?: { @@ -18,13 +19,17 @@ export type State = { message?:string | null; }; -const FormSchema = z.object({ +/** + * Schema for validating location form fields + * @description this is defined as factory function so that it can be used with the next-intl library +*/ +const FormSchema = (t:IntlTemplateFn) => z.object({ _id: z.string(), - locationName: z.coerce.string().min(1, "Location Name is required."), + locationName: z.coerce.string().min(1, t("location-name-required")), locationNotes: z.string(), - }); - -const UpdateLocation = FormSchema.omit({ _id: true }); + }) + // dont include the _id field in the response + .omit({ _id: true }); /** * Server-side action which adds or updates a bill @@ -37,7 +42,9 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat noStore(); - const validatedFields = UpdateLocation.safeParse({ + const t = await getTranslations("location-edit-form.validation"); + + const validatedFields = FormSchema(t).safeParse({ locationName: formData.get('locationName'), locationNotes: formData.get('locationNotes'), }); @@ -84,8 +91,6 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat }); } - // await asyncTimeout(1000); - if(yearMonth) await gotoHome(yearMonth); return { @@ -124,8 +129,6 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu }) .toArray(); - // await asyncTimeout(1000); - return(locations) }) @@ -154,8 +157,6 @@ export const fetchLocationById = withUser(async (user:AuthenticatedUser, locatio return(null); } - // await asyncTimeout(1000); - return(billLocation); }) @@ -170,7 +171,5 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati // find a location with the given locationID const post = await dbClient.collection("lokacije").deleteOne({ _id: locationID, userId }); - // await asyncTimeout(1000); - await gotoHome(yearMonth) }) \ No newline at end of file diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 5c3eb46..f7f94b2 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -2,6 +2,7 @@ import NextAuth, { NextAuthConfig } from 'next-auth'; import GoogleProvider from 'next-auth/providers/google'; import { Session } from 'next-auth'; import { AuthenticatedUser } from './types/next-auth'; +import { defaultLocale } from '../i18n'; export const authConfig: NextAuthConfig = { callbacks: { @@ -46,7 +47,7 @@ export const authConfig: NextAuthConfig = { strategy: 'jwt' }, pages: { - signIn: '/login', + signIn: `/${defaultLocale}/login`, }, }; diff --git a/app/location/[id]/add/LocationAddPage.tsx b/app/location/[id]/add/LocationAddPage.tsx deleted file mode 100644 index 9556980..0000000 --- a/app/location/[id]/add/LocationAddPage.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { LocationEditForm } from '@/app/ui/LocationEditForm'; -import { YearMonth } from '@/app/lib/db-types'; - -export default async function LocationAddPage({ yearMonth }: { yearMonth:YearMonth }) { - return (); -} \ No newline at end of file diff --git a/app/location/[id]/add/page.tsx b/app/location/[id]/add/page.tsx deleted file mode 100644 index 3f032fc..0000000 --- a/app/location/[id]/add/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { parseYearMonth } from '@/app/lib/format'; -import LocationAddPage from './LocationAddPage'; -import { Main } from '@/app/ui/Main'; - -export default async function Page({ params:{ id } }: { params: { id:string } }) { - return ( -
- -
- ); -} \ No newline at end of file diff --git a/app/location/[id]/delete/LocationDeletePage.tsx b/app/location/[id]/delete/LocationDeletePage.tsx deleted file mode 100644 index 7377733..0000000 --- a/app/location/[id]/delete/LocationDeletePage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { notFound } from 'next/navigation'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; -import { LocationDeleteForm } from '@/app/ui/LocationDeleteForm'; - -export const LocationDeletePage = async ({ locationId }: { locationId:string }) => { - - const location = await fetchLocationById(locationId); - - if (!location) { - return(notFound()); - } - - return (); -} \ No newline at end of file diff --git a/app/location/[id]/delete/not-found.tsx b/app/location/[id]/delete/not-found.tsx deleted file mode 100644 index 1587224..0000000 --- a/app/location/[id]/delete/not-found.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { NotFoundPage } from '@/app/ui/NotFoundPage'; - -const BillingLocationNotFound = () => -; - -export default BillingLocationNotFound; \ No newline at end of file diff --git a/app/location/[id]/delete/page.tsx b/app/location/[id]/delete/page.tsx deleted file mode 100644 index 40c7b8e..0000000 --- a/app/location/[id]/delete/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { notFound } from 'next/navigation'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; -import { LocationDeleteForm } from '@/app/ui/LocationDeleteForm'; -import { Main } from '@/app/ui/Main'; - -export default async function Page({ params:{ id } }: { params: { id:string } }) { - - const location = await fetchLocationById(id); - - if (!location) { - return(notFound()); - } - - return ( -
- -
- ); -} \ No newline at end of file diff --git a/app/location/[id]/edit/LocationEditPage.tsx b/app/location/[id]/edit/LocationEditPage.tsx deleted file mode 100644 index 505f634..0000000 --- a/app/location/[id]/edit/LocationEditPage.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { notFound } from 'next/navigation'; -import { LocationEditForm } from '@/app/ui/LocationEditForm'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; - -export default async function LocationEditPage({ locationId }: { locationId:string }) { - - const location = await fetchLocationById(locationId); - - if (!location) { - return(notFound()); - } - - const result = ; - - return (result); -} \ No newline at end of file diff --git a/app/location/[id]/edit/not-found.tsx b/app/location/[id]/edit/not-found.tsx deleted file mode 100644 index 54c9f60..0000000 --- a/app/location/[id]/edit/not-found.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { NotFoundPage } from '@/app/ui/NotFoundPage'; - -const BillingLocationNotFound = () => -; - -export default BillingLocationNotFound; \ No newline at end of file diff --git a/app/location/[id]/edit/page.tsx b/app/location/[id]/edit/page.tsx deleted file mode 100644 index 251f944..0000000 --- a/app/location/[id]/edit/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Suspense } from 'react'; -import LocationEditPage from './LocationEditPage'; -import { Main } from '@/app/ui/Main'; -import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; - -export default async function Page({ params:{ id } }: { params: { id:string } }) { - - return ( -
- }> - - -
- ); -} \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx deleted file mode 100644 index 054cadf..0000000 --- a/app/login/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { FC } from 'react'; -import { Main } from '@/app/ui/Main'; - -import { authConfig } from "@/app/lib/auth"; -import { SignInButton } from '../ui/SignInButton'; -import Image from 'next/image'; - -type Provider = { - id: string; - name: string; - type: string; - style: { - logo: string; - bg: string; - text: string; - }; -}; - -function getProviders(): Provider[] { - const providerKeys: (keyof Provider)[] = ["id", "name", "type", "style"]; - return authConfig.providers.map((provider) => - getKeyValuesFromObject(provider, providerKeys) - ); -} - -function getKeyValuesFromObject(obj: any, keys: (keyof T)[]): T { - return keys.reduce((acc, key) => { - if (obj[key]) { - acc[key] = obj[key]; - } - return acc; - }, {} as T); -} - -const Page:FC = async () => { - - const providers = await getProviders() - - return ( -
-

- Which bills are due? - Which are payed? - How much are my expenses? -

-

These are the questions this simple and free app will help you with ...

-

... try it & use it completly free!

- - { - Object.values(providers).map((provider) => ( -
- -
- )) - } -
- -

Easy copy of expenditures

-

All your realestate and utilitys are automatically copied to the next month, so you don't neeed to do it by hand.

- - -

Color signals status

-

Each of trhe utility bills is color coded - at a glance you can see which bill was received and which one is payed.

- Boje označavaju status računa - -

Extraction of 2D bar code

-

If the attached dokument contains a 2D bar code, it is automatically extracted and shown on the page, so you can scan it without opening the PDF document.

- -
- ); -} - -export default Page; \ No newline at end of file diff --git a/app/manifest.json b/app/manifest.json new file mode 100644 index 0000000..5238d66 --- /dev/null +++ b/app/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "App Režije", + "short_name": "Režije", + "icons": [ + { + "src": "/icon1.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/icon2.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/icon3.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "/icon4.png", + "sizes": "64x64", + "type": "image/png" + }, + { + "src": "/icon5.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "/icon6.png", + "sizes": "256x256", + "type": "image/png" + } + ], + "start_url": "/", + "scope": "/", + "theme_color": "#15191e", + "background_color": "#15191e", + "display": "standalone" +} diff --git a/app/ui/AddLocationButton.tsx b/app/ui/AddLocationButton.tsx index becd871..2f68524 100644 --- a/app/ui/AddLocationButton.tsx +++ b/app/ui/AddLocationButton.tsx @@ -2,20 +2,26 @@ import { PlusCircleIcon, HomeIcon } from "@heroicons/react/24/outline"; import { YearMonth } from "../lib/db-types"; import { formatYearMonth } from "../lib/format"; import Link from "next/link"; - +import { useTranslations } from 'next-intl'; export interface AddLocationButtonProps { /** year and month at which the new billing location should be addes */ yearMonth: YearMonth } -export const AddLocationButton:React.FC = ({yearMonth}) => -
- - - - - Add now
realestate
-
- -
; \ No newline at end of file +export const AddLocationButton:React.FC = ({yearMonth}) => { + + const t = useTranslations("home-page.add-location-button"); + + return( +
+ + + + + {t("tooltip")} + + +
+ ); +} \ No newline at end of file diff --git a/app/ui/AddMonthButton.tsx b/app/ui/AddMonthButton.tsx index 204df8c..ae9f1a5 100644 --- a/app/ui/AddMonthButton.tsx +++ b/app/ui/AddMonthButton.tsx @@ -3,18 +3,25 @@ import React from "react"; import { formatYearMonth } from "../lib/format"; import { YearMonth } from "../lib/db-types"; import Link from "next/link"; +import { useLocale, useTranslations } from 'next-intl'; export interface AddMonthButtonProps { yearMonth: YearMonth; } -export const AddMonthButton:React.FC = ({ yearMonth }) => -
- - - - - Add next
month
-
- -
; +export const AddMonthButton:React.FC = ({ yearMonth }) => { + + const t = useTranslations("home-page.add-month-button"); + const locale = useLocale(); + + return( +
+ + + + + {t("tooltip")} + + +
); +} diff --git a/app/ui/BillDeleteForm.tsx b/app/ui/BillDeleteForm.tsx index 7317d8f..fcb39d9 100644 --- a/app/ui/BillDeleteForm.tsx +++ b/app/ui/BillDeleteForm.tsx @@ -1,11 +1,12 @@ "use client"; -import { FC } from "react"; +import { FC, ReactNode } from "react"; import { Bill, BillingLocation } from "../lib/db-types"; import { useFormState } from "react-dom"; import { Main } from "./Main"; import { deleteBillById } from "../lib/actions/billActions"; import Link from "next/link"; +import { useTranslations } from "next-intl"; export interface BillDeleteFormProps { bill: Bill, @@ -17,18 +18,24 @@ export const BillDeleteForm:FC = ({ bill, location }) => { const { year, month } = location.yearMonth; const handleAction = deleteBillById.bind(null, location._id, bill._id, year, month); const [ state, dispatch ] = useFormState(handleAction, null); - + const t = useTranslations("bill-delete-form"); return(

- Please confirm deletion of bill “{bill.name}” at “{location.name}”. + { + t.rich("text", { + bill_name:bill.name, + location_name:location.name, + strong: (chunks:ReactNode) => `${chunks}`, + }) + }

- - Cancel + + {t("cancel-button")}
diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index 10fd36a..d27ba87 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -8,6 +8,7 @@ import { updateOrAddBill } from "../lib/actions/billActions"; import Link from "next/link"; import { formatYearMonth } from "../lib/format"; import { findDecodePdf417 } from "../lib/pdf/barcodeDecoder"; +import { useTranslations } from "next-intl"; // Next.js does not encode an utf-8 file name correctly when sending a form with a file attachment // This is a workaround for that @@ -25,6 +26,8 @@ export interface BillEditFormProps { export const BillEditForm:FC = ({ location, bill }) => { + const t = useTranslations("bill-edit-form"); + const { _id: billID, name, paid, attachment, notes, payedAmount: initialPayedAmount, barcodeImage: initialBarcodeImage } = bill ?? { _id:undefined, name:"", paid:false, notes:"" }; const { yearMonth:{year: billYear, month: billMonth}, _id: locationID } = location; @@ -69,12 +72,12 @@ export const BillEditForm:FC = ({ location, bill }) => { { // don't show the delete button if we are adding a new bill bill ? - + : null } - +
{state.errors?.billName && state.errors.billName.map((error: string) => ( @@ -107,13 +110,13 @@ export const BillEditForm:FC = ({ location, bill }) => {
@@ -134,11 +137,11 @@ export const BillEditForm:FC = ({ location, bill }) => { -

After scanning the code make sure the information is correct.
We are not liable in case of an incorrect payment.

+

{t.rich('barcode-disclaimer', { br: () =>
})}

: null } - +
{state.errors?.billNotes && state.errors.billNotes.map((error: string) => ( @@ -149,8 +152,8 @@ export const BillEditForm:FC = ({ location, bill }) => {
- - Cancel + + {t("cancel-button")}
diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index ae1d556..1f1120d 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -1,12 +1,13 @@ 'client only'; import { Cog8ToothIcon, PlusCircleIcon } from "@heroicons/react/24/outline"; -import { FC } from "react"; +import { FC, ReactNode } from "react"; import { BillBadge } from "./BillBadge"; import { BillingLocation } from "../lib/db-types"; import { formatYearMonth } from "../lib/format"; import { formatCurrency } from "../lib/formatStrings"; import Link from "next/link"; +import { useTranslations } from "next-intl"; export interface LocationCardProps { location: BillingLocation @@ -14,13 +15,15 @@ export interface LocationCardProps { export const LocationCard:FC = ({location: { _id, name, yearMonth, bills }}) => { + const t = useTranslations("home-page.location-card"); + // sum all the billAmounts const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); return(
- +

{formatYearMonth(yearMonth)} {name}

@@ -28,14 +31,18 @@ export const LocationCard:FC = ({location: { _id, name, yearM { bills.map(bill => ) } - +
{ monthlyExpense > 0 ?

- Payed total: { formatCurrency(monthlyExpense) } + { + t.rich("payed-total", { + amount: formatCurrency(monthlyExpense), + strong: (chunks:ReactNode) => `${chunks}` + })}

: null } diff --git a/app/ui/LocationDeleteForm.tsx b/app/ui/LocationDeleteForm.tsx index 7f6651d..d3b8c6e 100644 --- a/app/ui/LocationDeleteForm.tsx +++ b/app/ui/LocationDeleteForm.tsx @@ -1,11 +1,12 @@ "use client"; -import { FC } from "react"; +import { FC, ReactNode } from "react"; import { BillingLocation } from "../lib/db-types"; import { deleteLocationById } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; import { gotoUrl } from "../lib/actions/navigationActions"; import Link from "next/link"; +import { useTranslations } from "next-intl"; export interface LocationDeleteFormProps { /** location which should be deleted */ @@ -16,6 +17,8 @@ export const LocationDeleteForm:FC = ({ location }) => { const handleAction = deleteLocationById.bind(null, location._id, location.yearMonth); const [ state, dispatch ] = useFormState(handleAction, null); + const t = useTranslations("location-delete-form"); + const handleCancel = () => { gotoUrl(`/location/${location._id}/edit/`); @@ -26,11 +29,16 @@ export const LocationDeleteForm:FC = ({ location }) =>

- Please confirm deletion of location “{location.name}”. + { + t.rich("text", { + name:location.name, + strong: (chunks:ReactNode) => `${chunks}`, + }) + }

- - Cancel + + {t("cancel-button")}
diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index 78a5af5..ae8e144 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -6,6 +6,7 @@ import { BillingLocation, YearMonth } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; import Link from "next/link"; +import { useTranslations } from "next-intl"; export type LocationEditFormProps = { /** location which should be edited */ @@ -24,6 +25,7 @@ export const LocationEditForm:FC = ({ location, yearMonth const initialState = { message: null, errors: {} }; const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth); const [ state, dispatch ] = useFormState(handleAction, initialState); + const t = useTranslations("location-edit-form"); let { year, month } = location ? location.yearMonth : yearMonth; @@ -33,11 +35,11 @@ export const LocationEditForm:FC = ({ location, yearMonth
{ location && - + } - +
{state.errors?.locationName && state.errors.locationName.map((error: string) => ( @@ -47,7 +49,7 @@ export const LocationEditForm:FC = ({ location, yearMonth ))}
- +
{state.errors?.locationNotes && state.errors.locationNotes.map((error: string) => ( @@ -66,8 +68,8 @@ export const LocationEditForm:FC = ({ location, yearMonth }
- - Cancel + + {t("cancel-button")}
diff --git a/app/ui/Main.tsx b/app/ui/Main.tsx index 41b9637..3b9d044 100644 --- a/app/ui/Main.tsx +++ b/app/ui/Main.tsx @@ -1,16 +1,25 @@ import { FC } from "react"; import { PageHeader } from "./PageHeader"; import { PageFooter } from "./PageFooter"; +import { NextIntlClientProvider, useMessages } from "next-intl"; export interface MainProps { children: React.ReactNode; } -export const Main:FC = ({ children }) => -
- -
- {children} -
- -
\ No newline at end of file +export const Main:FC = ({ children }) => { + + const message = useMessages(); + + return( + +
+ +
+ {children} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/ui/MonthCard.tsx b/app/ui/MonthCard.tsx index 41e25ed..25e2583 100644 --- a/app/ui/MonthCard.tsx +++ b/app/ui/MonthCard.tsx @@ -4,7 +4,7 @@ import { FC, useEffect, useRef } from "react"; import { formatYearMonth } from "../lib/format"; import { YearMonth } from "../lib/db-types"; import { formatCurrency } from "../lib/formatStrings"; - +import { useTranslations } from "next-intl"; export interface MonthCardProps { yearMonth: YearMonth, @@ -17,6 +17,7 @@ export interface MonthCardProps { export const MonthCard:FC = ({ yearMonth, children, monthlyExpense, expanded, onToggle }) => { const elRef = useRef(null); + const t = useTranslations("home-page.month-card"); // Setting the `month` will activate the accordion belonging to that month // If the accordion is already active, it will collapse it @@ -37,7 +38,7 @@ export const MonthCard:FC = ({ yearMonth, children, monthlyExpen { monthlyExpense>0 ?

- Total monthly expenditure: { formatCurrency(monthlyExpense) } + {t("payed-total-label")} { formatCurrency(monthlyExpense) }

: null }
diff --git a/app/ui/PageFooter.tsx b/app/ui/PageFooter.tsx index 0e0cbf4..7fedf90 100644 --- a/app/ui/PageFooter.tsx +++ b/app/ui/PageFooter.tsx @@ -1,26 +1,33 @@ import Image from "next/image"; import Link from "next/link"; import React from "react"; +import { useTranslations } from "next-intl"; -export const PageFooter: React.FC = () => -
-
-
-
- logo -
Režije
+export const PageFooter: React.FC = () => { + + const t = useTranslations("PageFooter"); + + return( +
+
+
+
+ logo +
Režije
+
+

{t('app-description')}

+ {t('links.home')} + {t('links.privacy-policy')} + {t('links.terms-of-service')} +
+ -

App for helping you keeping track of your utility bills.

- Home - Privacy Policy - Terms of Service -
- -
-
; + +
+ ); +} \ No newline at end of file diff --git a/app/ui/PageHeader.tsx b/app/ui/PageHeader.tsx index 45867ce..b009479 100644 --- a/app/ui/PageHeader.tsx +++ b/app/ui/PageHeader.tsx @@ -1,7 +1,10 @@ import Image from "next/image"; import Link from "next/link"; +import { SelectLanguage } from "./SelectLanguage"; export const PageHeader = () => -
- logo Režije -
\ No newline at end of file +
+ logo Režije +   + +
\ No newline at end of file diff --git a/app/ui/SelectLanguage.tsx b/app/ui/SelectLanguage.tsx new file mode 100644 index 0000000..623aaa6 --- /dev/null +++ b/app/ui/SelectLanguage.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useLocale } from "next-intl"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { defaultLocale, localeNames, locales } from "../i18n"; + +export const SelectLanguage: React.FC = () => { + const currentPathname = usePathname(); + + const locale = useLocale(); + const secondLocale = locales.find((l) => l !== locale) as string; + const secondLocalePathname = defaultLocale === locale ? `/${secondLocale}${currentPathname}` : currentPathname.replace(`/${locale}/`, `/${secondLocale}/`); + + return ({localeNames[secondLocale]}); +} \ No newline at end of file diff --git a/app/ui/SignInButton.tsx b/app/ui/SignInButton.tsx index abfda18..c0c9940 100644 --- a/app/ui/SignInButton.tsx +++ b/app/ui/SignInButton.tsx @@ -1,6 +1,7 @@ "use client"; import { signIn } from "next-auth/react" +import { useTranslations } from "next-intl"; import Image from "next/image"; const providerLogo = (provider: {id:string, name:string}) => { @@ -14,10 +15,15 @@ const providerLogo = (provider: {id:string, name:string}) => { } } -export const SignInButton:React.FC<{ provider: {id:string, name:string} }> = ({ provider }) => - +export const SignInButton:React.FC<{ provider: {id:string, name:string} }> = ({ provider }) => { + const t = useTranslations("login-page"); + return( + + ); +} \ No newline at end of file diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index 74e466b..506de07 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -9,7 +9,7 @@ networks: services: web-app: - image: utility-bills-tracker:1.22.3 + image: utility-bills-tracker:1.25.0 networks: - traefik-network - mongo-network diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..08d78b5 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,99 @@ +{ + "Index": { + "title": "Welcome!" + }, + "PageFooter": { + "app-description": "Helping you to stay on top of your utility bills", + "links": { + "home": "Home", + "privacy-policy": "Privacy Policy", + "terms-of-service": "Terms of Service" + } + }, + "login-page": { + "main-card": { + "title-1": "Which bills are due?", + "title-2": "Which are payed?", + "title-3": "How much are my expenses?", + "text-1": "These are the questions this simple and free app will help you with ...", + "text-2": "... try it & use it completly free!", + "video-url": "/welcome-demo-vp9-25fps-1500bps.webm", + "image-url": "/hero.png", + "video-title": "Demo osnovnih koraka u aplikaciji" + }, + "card-1": { + "title": "Easy copy of expenditures", + "text": "All your realestate and utilitys are automatically copied to the next month, so you don't neeed to do it by hand.", + "video-url": "/kopiranje-mjeseca-demo.webm", + "image-url": "/status-color-demo.png", + "video-title": "Demo kopiranja mjeseca" + }, + "card-2": { + "title": "Color signals status", + "text": "Each of the utility bills is color coded - at a glance you can see which bill was received and which one is payed.", + "image-url": "/bar-code-demo.png", + "image-alt": "Boje označavaju status računa" + }, + "card-3": { + "title": "Color signals status", + "text": "If the attached dokument contains a 2D bar code, it is automatically extracted and shown on the page, so you can scan it without opening the PDF document.", + "video-url": "/welcome-demo-vp9-25fps-1500bps.webm", + "image-url": "/bar-code-demo.png", + "video-title": "Demo osnovnih koraka u aplikaciji" + }, + "sign-in-button": "Sign in with" + }, + "home-page": { + "add-location-button": { + "tooltop": "Add a new realestate" + }, + "add-month-button": { + "tooltop": "Add next mont" + }, + "location-card": { + "edit-card-tooltip": "Edit realestate", + "add-bill-button-tooltip": "Add a new bill", + "payed-total": "Payed total: {amount}" + }, + "month-card": { + "payed-total-label": "Total monthly expenditure:" + } + }, + "bill-delete-form": { + "text": "Please confirm deletion of bill “{bill_name}” at “{location_name}”.", + "cancel-button": "Cancel", + "confirm-button": "Confirm" + }, + "bill-edit-form": { + "bill-name-placeholder": "Bill name", + "paid-checkbox": "Paid", + "payed-amount": "Amount", + "barcode-disclaimer": "After scanning the code make sure the information is correct.
We are not liable in case of an incorrect payment.", + "notes-placeholder": "Notes", + "save-button": "Save", + "cancel-button": "Cancel", + "delete-tooltip": "Delete bill", + "validation": { + "bill-name-required": "Bill name is required", + "payed-amount-required": "Payed amount is required", + "not-a-number": "Not a number", + "negative-number": "Value must be a positive number", + "form-error-message": "Form validation error. Please check the form and try again." + } + }, + "location-delete-form": { + "text": "Please confirm deletion of realestate “{name}””.", + "cancel-button": "Cancel", + "confirm-button": "Confirm" + }, + "location-edit-form": { + "location-name-placeholder": "Realestate name", + "notes-placeholder": "Notes", + "save-button": "Save", + "cancel-button": "Cancel", + "delete-tooltip": "Delete realestate", + "validation": { + "location-name-required": "Relaestate name is required" + } + } +} \ No newline at end of file diff --git a/messages/hr.json b/messages/hr.json new file mode 100644 index 0000000..c8a3e1c --- /dev/null +++ b/messages/hr.json @@ -0,0 +1,98 @@ +{ + "Index": { + "title": "Dobrodošli" + }, + "PageFooter": { + "app-description": "Preuzmite kontrolu nad svojim režijama!", + "links": { + "home": "Početna", + "privacy-policy": "Privatnost", + "terms-of-service": "Uvjeti korištenja" + } + }, + "login-page": { + "main-card": { + "title-1": "Koji računi su stigli?", + "title-2": "Koji su plaćeni?", + "title-3": "Koliki su mi troškovi?", + "text-1": "To su pitanja na koja će vam ova jednostavna aplikacija odgovoriti ...", + "text-2": "... isprobajte je i koristite potpuno besplatno!", + "video-url": "/welcome-demo-vp9-25fps-1500bps.webm", + "image-url": "/hero.png", + "video-title": "Demo osnovnih koraka u aplikaciji" + }, + "card-1": { + "title": "Prijenos režija u idući mjesec", + "text": "Sve vaše nekretnine i pripadajuće režije se automatski prenose u idući mjesec, tako da ne morate svaki mjesec ponovno unositi iste podatke.", + "video-url": "/kopiranje-mjeseca-demo.webm", + "image-url": "/status-color-demo.png", + "video-title": "Demo kopiranja mjeseca" + }, + "card-2": { + "title": "Boja signalizira status", + "text": "Jednim pogledom možete vidjeti koji računi su plaćeni, a koji nisu. U tome vam pomaže boja koja označava status računa.", + "image-url": "/bar-code-demo.png", + "image-alt": "Boje označavaju status računa" + }, + "card-3": { + "title": "Prikaz bar koda za plaćanje", + "text": "Ako priloženi dokument sadrži 2D barkod, on se automatski izvlači i prikazuje na stranici, tako da ga možete skenirati bez otvaranja PDF dokumenta.", + "video-url": "/bar-code-demo.webm", + "image-url": "/bar-code-demo.png", + "video-title": "Demo osnovnih koraka u aplikaciji" + }, + "sign-in-button": "Prijavi se pomoću" + }, + "home-page": { + "add-location-button": { + "tooltop": "Dodaj novu nekretninu" + }, + "add-month-button": { + "tooltop": "Dodaj idući mjesec" + }, + "location-card": { + "edit-card-tooltip": "Izmjeni nekretninu", + "add-bill-button-tooltip": "Dodaj novi račun", + "payed-total": "Ukupno plaćeno: {amount}" + }, + "month-card": { + "payed-total-label": "Ukupni mjesečni trošak:" + } + }, + "bill-delete-form": { + "text": "Molim potvrdi brisanje računa “{bill_name}” koji pripada nekretnini “{location_name}”.", + "cancel-button": "Odustani", + "confirm-button": "Potvrdi" + }, + "bill-edit-form": { + "bill-name-placeholder": "Ime računa", + "paid-checkbox": "Plaćeno", + "payed-amount": "Iznos", + "barcode-disclaimer": "Nakon skeniranja bar koda obavezni provjeri jesu li svi podaci ispravni.
Ne snosimo odgovornost za slučaj pogrešno provedene uplate.", + "notes-placeholder": "Bilješke", + "save-button": "Spremi", + "cancel-button": "Odbaci", + "delete-tooltip": "Obriši račun", + "validation": { + "bill-name-required": "Ime računa je obavezno", + "not-a-number": "Vrijednost mora biti brojka", + "negative-number": "Vrijednost mora biti veća od nule", + "form-error-message": "Forma nije ispravno popunjena. Molimo provjeri, pa pokušaj ponovno" + } + }, + "location-delete-form": { + "text": "Molim potvrdi brisanje nekretnine “{name}””.", + "cancel-button": "Potvrdi", + "confirm-button": "Odustani" + }, + "location-edit-form": { + "location-name-placeholder": "Ime nekretnine", + "notes-placeholder": "Bilješke", + "save-button": "Spremi", + "cancel-button": "Odbaci", + "delete-tooltip": "Brisanje nekretnine", + "validation": { + "location-name-required": "Ime nekretnine je obavezno" + } + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index fa7dc5d..867b6fb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,11 +3,47 @@ * @description hooks-up `next-auth` into the page processing pipeline */ -import { auth } from '@/app/lib/auth' - -export default auth; // middleware will call NextAuth's `auth` method, which will in turn call) see `auth.ts` +import { auth, authConfig } from '@/app/lib/auth' +import createIntlMiddleware from 'next-intl/middleware'; +import { NextRequest, NextResponse } from 'next/server'; +import { locales, defaultLocale } from '@/app/i18n'; + +const publicPages = ['/terms', '/policy', '/login']; +const intlMiddleware = createIntlMiddleware({ + locales, + localePrefix: 'as-needed', + defaultLocale +}); + +export default async function middleware(req: NextRequest) { + const publicPathnameRegex = RegExp( + `^(/(${locales.join('|')}))?(${publicPages + .flatMap((p) => (p === '/' ? ['', '/'] : p)) + .join('|')})/?$`, + 'i' + ); + const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname); + + // for public pages we call only localisation middleware + // this is not an official way to do it - it's a hack + // based on https://github.com/nextauthjs/next-auth/discussions/8961 + // The official way of chaining middlewares in AuthJS v5 does not work and is not fully documented + if (!isPublicPage) { + const session = await auth(); + + if (!session) { + const signInUrl = `${req.nextUrl.protocol}//${req.nextUrl.hostname}${req.nextUrl.port ? `:${req.nextUrl.port}` : ''}${authConfig.pages?.signIn as string}`; + return NextResponse.redirect( signInUrl ); + } + } + + return intlMiddleware(req); +} + export const config = { - // midleware will NOT be called for paths: ['/api/auth/*', '/_next/static/*', '/_next/image*'] - matcher: ['/((?!api|policy|terms|_next/static|_next/image|.*\\.png$|.*\\.webm$).*)'], + // for these paths middleware will not be called + matcher: [ + '/((?!api|_next/static|_next/image|.*\\.png$|.*\\.webm$).*)', + ], }; \ No newline at end of file diff --git a/next.config.js b/next.config.js index 17c5061..9393371 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,5 @@ +const createNextIntlPlugin = require('next-intl/plugin'); + /** @type {import('next').NextConfig} */ const nextConfig = { // Possible options: @@ -16,4 +18,8 @@ const nextConfig = { } }; -module.exports = nextConfig; +const withNextIntl = createNextIntlPlugin('./app/i18n.ts'); + +const nextConfigIntl = withNextIntl(nextConfig); + +module.exports = nextConfigIntl; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f3fd4c6..82075a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "rezije", + "name": "evidencija-rezija", "lockfileVersion": 3, "requires": true, "packages": { @@ -17,6 +17,7 @@ "mongodb": "^6.3.0", "next": "^14.0.2", "next-auth": "^5.0.0-beta.4", + "next-intl": "^3.7.0", "pdfjs-dist": "^4.0.379", "pg": "^8.11.3", "postcss": "8.4.31", @@ -687,6 +688,92 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz", + "integrity": "sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==", + "dependencies": { + "@formatjs/intl-localematcher": "0.5.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", + "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz", + "integrity": "sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "@formatjs/icu-skeleton-parser": "1.3.6", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser/node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz", + "integrity": "sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser/node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@heroicons/react": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.18.tgz", @@ -4402,6 +4489,34 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.13.0.tgz", + "integrity": "sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.1.0", + "tslib": "^2.1.0" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -5348,6 +5463,14 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/next/-/next-14.0.2.tgz", @@ -5411,6 +5534,26 @@ } } }, + "node_modules/next-intl": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.7.0.tgz", + "integrity": "sha512-wLewkBzUbr/g2hKkI8/M1qYzHEVT4KgDeeayppvu+aDCJSOhfUFuYg0IlGn8+HNlgos2IPRRwZtFrTusiqW+uA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "dependencies": { + "@formatjs/intl-localematcher": "^0.2.32", + "negotiator": "^0.6.3", + "use-intl": "^3.7.0" + }, + "peerDependencies": { + "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -7664,6 +7807,18 @@ "react": ">=16.8.0" } }, + "node_modules/use-intl": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.7.0.tgz", + "integrity": "sha512-WxbrBDMWgRvoLMlvlE0LWrPmGKRydinWeatpu7QWT/ennqp0pP6Z3YwrUdPthjguByOKi/Vr5FViXhq/Fd5ifg==", + "dependencies": { + "@formatjs/ecma402-abstract": "^1.11.4", + "intl-messageformat": "^9.3.18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index b832557..1c89582 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "mongodb": "^6.3.0", "next": "^14.0.2", "next-auth": "^5.0.0-beta.4", + "next-intl": "^3.7.0", "pdfjs-dist": "^4.0.379", "pg": "^8.11.3", "postcss": "8.4.31", diff --git a/public/opengraph-image.png b/public/opengraph-image.png new file mode 100644 index 0000000..e0c3d13 Binary files /dev/null and b/public/opengraph-image.png differ