Merge branch 'release/1.25.0'

This commit is contained in:
2024-02-17 09:02:07 +01:00
49 changed files with 818 additions and 317 deletions

View File

@@ -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
View 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>
);
}

View 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;

View File

@@ -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?: {

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import { Main } from "../ui/Main";
import { Main } from "@/app/ui/Main";
const TermsPage = () =>
<Main>

30
app/i18n.ts Normal file
View 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
};
});

View File

@@ -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>
);
}

View File

@@ -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"),
});
}

View File

@@ -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)
})

View File

@@ -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`,
},
};

View File

@@ -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} />);
}

View File

@@ -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>
);
}

View File

@@ -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} />);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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&apos;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
View 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"
}

View File

@@ -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>
);
}

View File

@@ -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>);
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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">&nbsp;</span>
<SelectLanguage />
</div>

16
app/ui/SelectLanguage.tsx Normal file
View 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>);
}

View File

@@ -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>
);
}

View File

@@ -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
View 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
View 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"
}
}
}

View File

@@ -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$).*)',
],
};

View File

@@ -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
View File

@@ -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",

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB