Merge branch 'release/1.25.0'
This commit is contained in:
@@ -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
|
||||
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
|
||||
49
app/[locale]/layout.tsx
Normal file
49
app/[locale]/layout.tsx
Normal file
@@ -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 (
|
||||
<html lang={locale}>
|
||||
<body className={`${inter.className} antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
82
app/[locale]/login/page.tsx
Normal file
82
app/[locale]/login/page.tsx
Normal file
@@ -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>(provider, providerKeys)
|
||||
);
|
||||
}
|
||||
|
||||
function getKeyValuesFromObject<T>(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 (
|
||||
<Main>
|
||||
<h1 className="text-3xl font-bold text-center">
|
||||
<span className="text-neutral-50 mr-3">{t("main-card.title-1")}</span>
|
||||
<span className="text-indigo-400">{t("main-card.title-2")}</span>
|
||||
<span className="text-neutral-50 ml-3">{t("main-card.title-3")}</span>
|
||||
</h1>
|
||||
<p className="p mt-[1em] text-center">{t("main-card.text-1")}</p>
|
||||
<p className="p mb-[1em] text-center">{t("main-card.text-2")}</p>
|
||||
<span className="text-center">
|
||||
{
|
||||
Object.values(providers).map((provider) => (
|
||||
<div key={provider.name}>
|
||||
<SignInButton provider={provider} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
<video className="m-auto mt-4" title={t("main-card.video-title")} role="img" data-js-id="hero" loop muted playsInline autoPlay poster={t("main-card.image-url")}>
|
||||
<source src={t("main-card.video-url")} type="video/webm" />
|
||||
</video>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-50 my-5">{t("card-1.title")}</h1>
|
||||
<p className="p mt-[1em]">{t("card-1.text")}</p>
|
||||
<video className="m-auto mt-4" title={t("card-1.video-title")} role="img" data-js-id="hero" loop muted playsInline autoPlay poster={t("card-1.image-url")}>
|
||||
<source src={t("card-1.video-url")} type="video/webm" />
|
||||
</video>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-50 my-5">{t("card-2.title")}</h1>
|
||||
<p className="p mt-[1em]">{t("card-2.text")}</p>
|
||||
<Image src="/status-color-demo.png" alt="Boje označavaju status računa" className="m-auto mt-4" width={423} height={145} />
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-50 my-5">{t("card-3.title")}</h1>
|
||||
<p className="p mt-[1em]">{t("card-3.text")}</p>
|
||||
<video className="m-auto mt-4" title={t("card-3.video-title")} role="img" data-js-id="hero" loop muted playsInline autoPlay poster={t("card-3.image-url")}>
|
||||
<source src={t("card-3.video-url")} type="video/webm" />
|
||||
</video>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
@@ -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?: {
|
||||
@@ -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 = () =>
|
||||
<Main>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Main } from "../ui/Main";
|
||||
import { Main } from "@/app/ui/Main";
|
||||
|
||||
const TermsPage = () =>
|
||||
<Main>
|
||||
30
app/i18n.ts
Normal file
30
app/i18n.ts
Normal file
@@ -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<string,string> = {
|
||||
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`
|
||||
(<TargetKey extends any>(key: TargetKey, values?: TranslationValues | undefined, formats?: Partial<Formats> | undefined) => string) |
|
||||
// this functon type if returned by `getTranslations`
|
||||
(<TargetKey extends any>(key: [TargetKey] extends [never] ? string : TargetKey, values?: TranslationValues | undefined, formats?: Partial<Formats> | 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
|
||||
};
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import '@/app/ui/global.css';
|
||||
import { inter } from '@/app/ui/fonts';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
|
||||
|
||||
// await asyncTimeout(1000);
|
||||
|
||||
await gotoHome(yearMonth)
|
||||
})
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (<LocationEditForm yearMonth={yearMonth} />);
|
||||
}
|
||||
@@ -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 (
|
||||
<Main>
|
||||
<LocationAddPage yearMonth={ parseYearMonth(id) } />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -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 (<LocationDeleteForm location={location} />);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NotFoundPage } from '@/app/ui/NotFoundPage';
|
||||
|
||||
const BillingLocationNotFound = () =>
|
||||
<NotFoundPage title="404 Billing Location Not Found" description="Could not find the requested Billing Location." />;
|
||||
|
||||
export default BillingLocationNotFound;
|
||||
@@ -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 (
|
||||
<Main>
|
||||
<LocationDeleteForm location={location} />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -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 = <LocationEditForm location={location} />;
|
||||
|
||||
return (result);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NotFoundPage } from '@/app/ui/NotFoundPage';
|
||||
|
||||
const BillingLocationNotFound = () =>
|
||||
<NotFoundPage title="404 Location Not Found" description="Could not find the requested Location." />;
|
||||
|
||||
export default BillingLocationNotFound;
|
||||
@@ -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 (
|
||||
<Main>
|
||||
<Suspense fallback={<LocationEditFormSkeleton />}>
|
||||
<LocationEditPage locationId={id} />
|
||||
</Suspense>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -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>(provider, providerKeys)
|
||||
);
|
||||
}
|
||||
|
||||
function getKeyValuesFromObject<T>(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 (
|
||||
<Main>
|
||||
<h1 className="text-3xl font-bold text-center">
|
||||
<span className="text-neutral-50 mr-3">Which bills are due?</span>
|
||||
<span className="text-indigo-400">Which are payed?</span>
|
||||
<span className="text-neutral-50 ml-3">How much are my expenses?</span>
|
||||
</h1>
|
||||
<p className="p mt-[1em] text-center">These are the questions this simple and free app will help you with ...</p>
|
||||
<p className="p mb-[1em] text-center">... try it & use it completly free!</p>
|
||||
<span className="text-center">
|
||||
{
|
||||
Object.values(providers).map((provider) => (
|
||||
<div key={provider.name}>
|
||||
<SignInButton provider={provider} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
<video className="m-auto mt-4" title="Demo osnovnih koraka u aplikaciji" role="img" data-js-id="hero" loop muted playsInline autoPlay poster="hero.png">
|
||||
<source src="/welcome-demo-vp9-25fps-1500bps.webm" type="video/webm" />
|
||||
</video>
|
||||
<h1 className="text-2xl font-bold text-neutral-50 my-5">Easy copy of expenditures</h1>
|
||||
<p className="p mt-[1em]">All your realestate and utilitys are automatically copied to the next month, so you don't neeed to do it by hand.</p>
|
||||
<video className="m-auto mt-4" title="Demo kopiranja mjeseca" role="img" data-js-id="hero" loop muted playsInline autoPlay poster="bar-code-demo.png">
|
||||
<source src="/kopiranje-mjeseca-demo.webm" type="video/webm" />
|
||||
</video>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-50 my-5">Color signals status</h1>
|
||||
<p className="p mt-[1em]">Each of trhe utility bills is color coded - at a glance you can see which bill was received and which one is payed.</p>
|
||||
<Image src="/status-color-demo.png" alt="Boje označavaju status računa" className="m-auto mt-4" width={423} height={145} />
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-50 my-5">Extraction of 2D bar code</h1>
|
||||
<p className="p mt-[1em]">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.</p>
|
||||
<video className="m-auto mt-4" title="Demo generiranja 2D bar koda" role="img" data-js-id="hero" loop muted playsInline autoPlay poster="bar-code-demo.png">
|
||||
<source src="/bar-code-demo.webm" type="video/webm" />
|
||||
</video>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
41
app/manifest.json
Normal file
41
app/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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<AddLocationButtonProps> = ({yearMonth}) =>
|
||||
<div className="card card-compact card-bordered bg-base-100 shadow-s my-1">
|
||||
<Link href={`/location/${ formatYearMonth(yearMonth) }/add`} className="card-body tooltip self-center" data-tip="Add a new realestate">
|
||||
<span className='flex self-center mr-[-3em]' data-tip="Add a new realestate">
|
||||
<HomeIcon className="h-[1em] w-[1em] cursor-pointer text-4xl" />
|
||||
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-xl text-green-500 ml-[-.6em] mt-[-.4em]" />
|
||||
<span className="ml-1 mt-[.4em] text-xs text-left leading-[1.2em]">Add now<br/>realestate</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>;
|
||||
export const AddLocationButton:React.FC<AddLocationButtonProps> = ({yearMonth}) => {
|
||||
|
||||
const t = useTranslations("home-page.add-location-button");
|
||||
|
||||
return(
|
||||
<div className="card card-compact card-bordered bg-base-100 shadow-s my-1">
|
||||
<Link href={`/location/${ formatYearMonth(yearMonth) }/add`} className="card-body tooltip self-center" data-tip={t("tooltip")}>
|
||||
<span className='flex self-center mr-[-3em]'>
|
||||
<HomeIcon className="h-[1em] w-[1em] cursor-pointer text-4xl" />
|
||||
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-xl text-green-500 ml-[-.6em] mt-[-.4em]" />
|
||||
<span className="ml-1 mt-[.4em] text-xs text-left leading-[1.2em]">{t("tooltip")}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AddMonthButtonProps> = ({ yearMonth }) =>
|
||||
<div className="card card-compact shadow-s mb-4">
|
||||
<Link href={`/year-month/${formatYearMonth(yearMonth)}/add`} className='grid self-center tooltip' data-tip="Add next month">
|
||||
<span className='flex self-center mr-[-3em]'>
|
||||
<CalendarDaysIcon className="h-[1em] w-[1em] cursor-pointer text-4xl" />
|
||||
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-xl text-green-500 ml-[-.4em] mt-[-.4em]" />
|
||||
<span className="ml-1 mt-1 text-xs text-left leading-[1.2em]">Add next<br/>month</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>;
|
||||
export const AddMonthButton:React.FC<AddMonthButtonProps> = ({ yearMonth }) => {
|
||||
|
||||
const t = useTranslations("home-page.add-month-button");
|
||||
const locale = useLocale();
|
||||
|
||||
return(
|
||||
<div className="card card-compact shadow-s mb-4">
|
||||
<Link href={`/${locale}/year-month/${formatYearMonth(yearMonth)}/add`} className='grid self-center tooltip' data-tip={t("tooltip")}>
|
||||
<span className='flex self-center mr-[-3em]'>
|
||||
<CalendarDaysIcon className="h-[1em] w-[1em] cursor-pointer text-4xl" />
|
||||
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-xl text-green-500 ml-[-.4em] mt-[-.4em]" />
|
||||
<span className="ml-1 mt-1 text-xs text-left leading-[1.2em]">{t("tooltip")}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -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<BillDeleteFormProps> = ({ 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(
|
||||
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
|
||||
<div className="card-body">
|
||||
<form action={dispatch}>
|
||||
<p className="py-6 px-6">
|
||||
Please confirm deletion of bill “<strong>{bill.name}</strong>” at “<strong>{location.name}</strong>”.
|
||||
{
|
||||
t.rich("text", {
|
||||
bill_name:bill.name,
|
||||
location_name:location.name,
|
||||
strong: (chunks:ReactNode) => `<strong>${chunks}</strong>`,
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<div className="pt-4 text-center">
|
||||
<button className="btn btn-primary">Confim</button>
|
||||
<Link className="btn btn-neutral ml-3" href={`/bill/${location._id}-${bill._id}/edit/`}>Cancel</Link>
|
||||
<button className="btn btn-primary">{t("confirm-button")}</button>
|
||||
<Link className="btn btn-neutral ml-3" href={`/bill/${location._id}-${bill._id}/edit/`}>{t("cancel-button")}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<BillEditFormProps> = ({ 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<BillEditFormProps> = ({ location, bill }) => {
|
||||
{
|
||||
// don't show the delete button if we are adding a new bill
|
||||
bill ?
|
||||
<Link href={`/bill/${locationID}-${billID}/delete/`}>
|
||||
<Link href={`/bill/${locationID}-${billID}/delete/`} data-tip={t("delete-tooltip")}>
|
||||
<TrashIcon className="h-[1em] w-[1em] absolute cursor-pointer text-error bottom-5 right-4 text-2xl" />
|
||||
</Link> : null
|
||||
}
|
||||
|
||||
<input id="billName" name="billName" type="text" placeholder="Bill name" className="input input-bordered w-full" defaultValue={name} required />
|
||||
<input id="billName" name="billName" type="text" placeholder={t("bill-name-placeholder")} className="input input-bordered w-full" defaultValue={name} required />
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.billName &&
|
||||
state.errors.billName.map((error: string) => (
|
||||
@@ -107,13 +110,13 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
||||
<div className="flex">
|
||||
<div className="form-control flex-row">
|
||||
<label className="cursor-pointer label align-middle">
|
||||
<span className="label-text mr-[1em]">Paid</span>
|
||||
<span className="label-text mr-[1em]">{t("paid-checkbox")}</span>
|
||||
<input id="billPaid" name="billPaid" type="checkbox" className="toggle toggle-success" defaultChecked={paid} onChange={billPaid_handleChange} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-control grow">
|
||||
<label className="cursor-pointer label grow">
|
||||
<span className="label-text mx-[1em]">Amount</span>
|
||||
<span className="label-text mx-[1em]">{t("payed-amount")}</span>
|
||||
<input type="text" id="payedAmount" name="payedAmount" className="input input-bordered text-right w-[5em] grow" placeholder="0.00" value={payedAmount} onFocus={e => e.target.select()} onChange={payedAmount_handleChange} />
|
||||
</label>
|
||||
</div>
|
||||
@@ -134,11 +137,11 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
||||
<label className="cursor-pointer label p-2 grow bg-white">
|
||||
<img src={barcodeImage} className="grow sm:max-w-[350px]" alt="2D Barcode" />
|
||||
</label>
|
||||
<p className="text-xs my-1">After scanning the code make sure the information is correct.<br/>We are not liable in case of an incorrect payment.</p>
|
||||
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
<textarea id="billNotes" name="billNotes" className="textarea textarea-bordered my-2 max-w-lg w-full block" placeholder="Note" defaultValue={notes ?? ''}></textarea>
|
||||
<textarea id="billNotes" name="billNotes" className="textarea textarea-bordered my-2 max-w-lg w-full block" placeholder={t("notes-placeholder")} defaultValue={notes ?? ''}></textarea>
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.billNotes &&
|
||||
state.errors.billNotes.map((error: string) => (
|
||||
@@ -149,8 +152,8 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
<Link className="btn btn-neutral ml-3" href={`/?year=${billYear}&month=${billMonth}`}>Cancel</Link>
|
||||
<button type="submit" className="btn btn-primary">{t("save-button")}</button>
|
||||
<Link className="btn btn-neutral ml-3" href={`/?year=${billYear}&month=${billMonth}`}>{t("cancel-button")}</Link>
|
||||
</div>
|
||||
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
|
||||
@@ -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<LocationCardProps> = ({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(
|
||||
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] bg-base-100 border-1 border-neutral my-1">
|
||||
<div className="card-body">
|
||||
<Link href={`/location/${_id}/edit`} className="card-subtitle tooltip" data-tip="Edit Location">
|
||||
<Link href={`/location/${_id}/edit`} className="card-subtitle tooltip" data-tip={t("edit-card-tooltip")}>
|
||||
<Cog8ToothIcon className="h-[1em] w-[1em] absolute cursor-pointer top-3 right-3 text-2xl" />
|
||||
</Link>
|
||||
<h2 className="card-title mr-[2em] text-[1rem]">{formatYearMonth(yearMonth)} {name}</h2>
|
||||
@@ -28,14 +31,18 @@ export const LocationCard:FC<LocationCardProps> = ({location: { _id, name, yearM
|
||||
{
|
||||
bills.map(bill => <BillBadge key={`${_id}-${bill._id}`} locationId={_id} bill={bill} />)
|
||||
}
|
||||
<Link href={`/bill/${_id}/add`} className="tooltip" data-tip="Add a new bill">
|
||||
<Link href={`/bill/${_id}/add`} className="tooltip" data-tip={t("add-bill-button-tooltip")}>
|
||||
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-2xl" />
|
||||
</Link>
|
||||
</div>
|
||||
{
|
||||
monthlyExpense > 0 ?
|
||||
<p>
|
||||
Payed total: <strong>{ formatCurrency(monthlyExpense) }</strong>
|
||||
{
|
||||
t.rich("payed-total", {
|
||||
amount: formatCurrency(monthlyExpense),
|
||||
strong: (chunks:ReactNode) => `<strong>${chunks}</strong>`
|
||||
})}
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -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<LocationDeleteFormProps> = ({ 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<LocationDeleteFormProps> = ({ location }) =>
|
||||
<div className="card-body">
|
||||
<form action={dispatch}>
|
||||
<p className="py-6 px-6">
|
||||
Please confirm deletion of location “<strong>{location.name}</strong>”.
|
||||
{
|
||||
t.rich("text", {
|
||||
name:location.name,
|
||||
strong: (chunks:ReactNode) => `<strong>${chunks}</strong>`,
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<div className="pt-4 text-center">
|
||||
<button className="btn btn-primary w-[5.5em]">Confim</button>
|
||||
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/location/${location._id}/edit/`}>Cancel</Link>
|
||||
<button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button>
|
||||
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/location/${location._id}/edit/`}>{t("cancel-button")}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<LocationEditFormProps> = ({ 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<LocationEditFormProps> = ({ location, yearMonth
|
||||
<form action={dispatch}>
|
||||
{
|
||||
location &&
|
||||
<Link href={`/location/${location._id}/delete`} className="absolute bottom-5 right-4 tooltip" data-tip="Delete Location">
|
||||
<Link href={`/location/${location._id}/delete`} className="absolute bottom-5 right-4 tooltip" data-tip={t("delete-tooltip")}>
|
||||
<TrashIcon className="h-[1em] w-[1em] text-error text-2xl" />
|
||||
</Link>
|
||||
}
|
||||
<input id="locationName" name="locationName" type="text" placeholder="Realestate name" className="input input-bordered w-full" defaultValue={location?.name ?? ""} />
|
||||
<input id="locationName" name="locationName" type="text" placeholder={t("location-name-placeholder")} className="input input-bordered w-full" defaultValue={location?.name ?? ""} />
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.locationName &&
|
||||
state.errors.locationName.map((error: string) => (
|
||||
@@ -47,7 +49,7 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea id="locationNotes" name="locationNotes" className="textarea textarea-bordered my-1 w-full block h-[8em]" placeholder="Description" defaultValue={location?.notes ?? ""}></textarea>
|
||||
<textarea id="locationNotes" name="locationNotes" className="textarea textarea-bordered my-1 w-full block h-[8em]" placeholder={t("notes-placeholder")} defaultValue={location?.notes ?? ""}></textarea>
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.locationNotes &&
|
||||
state.errors.locationNotes.map((error: string) => (
|
||||
@@ -66,8 +68,8 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
|
||||
}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<button className="btn btn-primary w-[5.5em]">Save</button>
|
||||
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/?year=${year}&month=${month}`}>Cancel</Link>
|
||||
<button className="btn btn-primary w-[5.5em]">{t("save-button")}</button>
|
||||
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/?year=${year}&month=${month}`}>{t("cancel-button")}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<MainProps> = ({ children }) =>
|
||||
<main className="flex min-h-screen flex-col bg-base-300">
|
||||
<PageHeader />
|
||||
<div className="sm:mx-auto px-4">
|
||||
{children}
|
||||
</div>
|
||||
<PageFooter />
|
||||
</main>
|
||||
export const Main:FC<MainProps> = ({ children }) => {
|
||||
|
||||
const message = useMessages();
|
||||
|
||||
return(
|
||||
<NextIntlClientProvider messages={message}>
|
||||
<main className="flex min-h-screen flex-col bg-base-300">
|
||||
<PageHeader />
|
||||
<div className="sm:mx-auto px-4">
|
||||
{children}
|
||||
</div>
|
||||
<PageFooter />
|
||||
</main>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<MonthCardProps> = ({ yearMonth, children, monthlyExpense, expanded, onToggle }) => {
|
||||
|
||||
const elRef = useRef<HTMLDivElement>(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<MonthCardProps> = ({ yearMonth, children, monthlyExpen
|
||||
{
|
||||
monthlyExpense>0 ?
|
||||
<p className="text-xs font-medium">
|
||||
Total monthly expenditure: <strong>{ formatCurrency(monthlyExpense) }</strong>
|
||||
{t("payed-total-label")} <strong>{ formatCurrency(monthlyExpense) }</strong>
|
||||
</p> : null
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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 = () =>
|
||||
<div className="bg-base-100 text-base-content mt-10">
|
||||
<footer className="footer mx-auto max-w-2xl px-4 py-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/icon4.png" alt="logo" width={64} height={64}></Image>
|
||||
<div className="font-title inline-flex text-3xl font-black ml-2">Režije</div>
|
||||
export const PageFooter: React.FC = () => {
|
||||
|
||||
const t = useTranslations("PageFooter");
|
||||
|
||||
return(
|
||||
<div className="bg-base-100 text-base-content mt-10">
|
||||
<footer className="footer mx-auto max-w-2xl px-4 py-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/icon4.png" alt="logo" width={64} height={64}></Image>
|
||||
<div className="font-title inline-flex text-3xl font-black ml-2">Režije</div>
|
||||
</div>
|
||||
<p className="text-base-content/70 mb-5">{t('app-description')}</p>
|
||||
<Link href="/" className="link link-hover">{t('links.home')}</Link>
|
||||
<Link href="/policy/" className="link link-hover">{t('links.privacy-policy')}</Link>
|
||||
<Link href="/terms/" className="link link-hover">{t('links.terms-of-service')}</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="footer-title opacity-70">documents</span>
|
||||
<a href="https://tailwindcss.com/docs/" target="_blank" className="link link-hover">tailwindcss.com</a>
|
||||
<a href="https://heroicons.com/" target="_blank" className="link link-hover">heroicons.com</a>
|
||||
<a href="https://daisyui.com/components/" target="_blank" className="link link-hover">daisyui.com</a>
|
||||
</div>
|
||||
<p className="text-base-content/70 mb-5">App for helping you keeping track of your utility bills.</p>
|
||||
<Link href="/" className="link link-hover">Home</Link>
|
||||
<Link href="/policy/" className="link link-hover">Privacy Policy</Link>
|
||||
<Link href="/terms/" className="link link-hover">Terms of Service</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="footer-title opacity-70">documents</span>
|
||||
<a href="https://tailwindcss.com/docs/" target="_blank" className="link link-hover">tailwindcss.com</a>
|
||||
<a href="https://heroicons.com/" target="_blank" className="link link-hover">heroicons.com</a>
|
||||
<a href="https://daisyui.com/components/" target="_blank" className="link link-hover">daisyui.com</a>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
</div>;
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SelectLanguage } from "./SelectLanguage";
|
||||
|
||||
export const PageHeader = () =>
|
||||
<div className="navbar bg-base-100 mb-6">
|
||||
<Link className="btn btn-ghost text-xl" href="/"><Image src="/icon3.png" alt="logo" width={48} height={48} /> Režije</Link>
|
||||
</div>
|
||||
<div className="navbar bg-base-100 mb-6">
|
||||
<Link className="btn btn-ghost text-xl" href="/"><Image src="/icon3.png" alt="logo" width={48} height={48} /> Režije</Link>
|
||||
<span className="grow"> </span>
|
||||
<SelectLanguage />
|
||||
</div>
|
||||
16
app/ui/SelectLanguage.tsx
Normal file
16
app/ui/SelectLanguage.tsx
Normal file
@@ -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 (<Link className="btn btn-ghost text-xl self-end" href={secondLocalePathname}>{localeNames[secondLocale]}</Link>);
|
||||
}
|
||||
@@ -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 }) =>
|
||||
<button className="btn btn-neutral" onClick={() => signIn(provider.id, { callbackUrl:"https://rezije.app/" }) }>
|
||||
<Image alt="Provider Logo" loading="lazy" height="24" width="24" id="provider-logo-dark" src={providerLogo(provider)} />
|
||||
<span>Sign in with {provider.name}</span>
|
||||
</button>
|
||||
export const SignInButton:React.FC<{ provider: {id:string, name:string} }> = ({ provider }) => {
|
||||
|
||||
const t = useTranslations("login-page");
|
||||
|
||||
return(
|
||||
<button className="btn btn-neutral" onClick={() => signIn(provider.id, { callbackUrl:"https://rezije.app/" }) }>
|
||||
<Image alt="Provider Logo" loading="lazy" height="24" width="24" id="provider-logo-dark" src={providerLogo(provider)} />
|
||||
<span>
|
||||
{t("sign-in-button")} {provider.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
99
messages/en.json
Normal file
99
messages/en.json
Normal file
@@ -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: <strong>{amount}</strong>"
|
||||
},
|
||||
"month-card": {
|
||||
"payed-total-label": "Total monthly expenditure:"
|
||||
}
|
||||
},
|
||||
"bill-delete-form": {
|
||||
"text": "Please confirm deletion of bill “<strong>{bill_name}</strong>” at “<strong>{location_name}</strong>”.",
|
||||
"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.<br/>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 “<strong>{name}</strong>””.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
messages/hr.json
Normal file
98
messages/hr.json
Normal file
@@ -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: <strong>{amount}</strong>"
|
||||
},
|
||||
"month-card": {
|
||||
"payed-total-label": "Ukupni mjesečni trošak:"
|
||||
}
|
||||
},
|
||||
"bill-delete-form": {
|
||||
"text": "Molim potvrdi brisanje računa “<strong>{bill_name}</strong>” koji pripada nekretnini “<strong>{location_name}</strong>”.",
|
||||
"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.<br/>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 “<strong>{name}</strong>””.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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$).*)',
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
157
package-lock.json
generated
157
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/opengraph-image.png
Normal file
BIN
public/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Reference in New Issue
Block a user