Merge branch 'release/2.2.0'

This commit is contained in:
Knee Cola
2025-11-24 18:42:34 +01:00
13 changed files with 491 additions and 194 deletions

View File

@@ -1,6 +1,8 @@
import { LocationEditForm } from '@/app/ui/LocationEditForm'; import { LocationEditForm } from '@/app/ui/LocationEditForm';
import { YearMonth } from '@/app/lib/db-types'; import { YearMonth } from '@/app/lib/db-types';
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
export default async function LocationAddPage({ yearMonth }: { yearMonth:YearMonth }) { export default async function LocationAddPage({ yearMonth }: { yearMonth:YearMonth }) {
return (<LocationEditForm yearMonth={yearMonth} />); const userSettings = await getUserSettings();
return (<LocationEditForm yearMonth={yearMonth} userSettings={userSettings} />);
} }

View File

@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { LocationEditForm } from '@/app/ui/LocationEditForm'; import { LocationEditForm } from '@/app/ui/LocationEditForm';
import { fetchLocationById } from '@/app/lib/actions/locationActions'; import { fetchLocationById } from '@/app/lib/actions/locationActions';
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
export default async function LocationEditPage({ locationId }: { locationId:string }) { export default async function LocationEditPage({ locationId }: { locationId:string }) {
@@ -10,7 +11,9 @@ export default async function LocationEditPage({ locationId }: { locationId:stri
return(notFound()); return(notFound());
} }
const result = <LocationEditForm location={location} />; const userSettings = await getUserSettings();
const result = <LocationEditForm location={location} userSettings={userSettings} />;
return (result); return (result);
} }

View File

@@ -14,7 +14,6 @@ import { getTranslations, getLocale } from "next-intl/server";
export type State = { export type State = {
errors?: { errors?: {
locationName?: string[]; locationName?: string[];
generateTenantCode?: string[];
tenantName?: string[]; tenantName?: string[];
tenantStreet?: string[]; tenantStreet?: string[];
tenantTown?: string[]; tenantTown?: string[];
@@ -35,7 +34,7 @@ export type State = {
const FormSchema = (t:IntlTemplateFn) => z.object({ const FormSchema = (t:IntlTemplateFn) => z.object({
_id: z.string(), _id: z.string(),
locationName: z.coerce.string().min(1, t("location-name-required")), locationName: z.coerce.string().min(1, t("location-name-required")),
generateTenantCode: z.boolean().optional().nullable(), tenantPaymentMethod: z.enum(["none", "iban", "revolut"]).optional().nullable(),
tenantName: z.string().max(30).optional().nullable(), tenantName: z.string().max(30).optional().nullable(),
tenantStreet: z.string().max(27).optional().nullable(), tenantStreet: z.string().max(27).optional().nullable(),
tenantTown: z.string().max(27).optional().nullable(), tenantTown: z.string().max(27).optional().nullable(),
@@ -50,9 +49,9 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
}) })
// dont include the _id field in the response // dont include the _id field in the response
.omit({ _id: true }) .omit({ _id: true })
// Add conditional validation: if generateTenantCode is true, tenant fields are required // Add conditional validation: if `tenantPaymentMethod` is "iban", tenant fields are required
.refine((data) => { .refine((data) => {
if (data.generateTenantCode) { if (data.tenantPaymentMethod === "iban") {
return !!data.tenantName && data.tenantName.trim().length > 0; return !!data.tenantName && data.tenantName.trim().length > 0;
} }
return true; return true;
@@ -61,7 +60,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
path: ["tenantName"], path: ["tenantName"],
}) })
.refine((data) => { .refine((data) => {
if (data.generateTenantCode) { if (data.tenantPaymentMethod === "iban") {
return !!data.tenantStreet && data.tenantStreet.trim().length > 0; return !!data.tenantStreet && data.tenantStreet.trim().length > 0;
} }
return true; return true;
@@ -70,7 +69,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
path: ["tenantStreet"], path: ["tenantStreet"],
}) })
.refine((data) => { .refine((data) => {
if (data.generateTenantCode) { if (data.tenantPaymentMethod === "iban") {
return !!data.tenantTown && data.tenantTown.trim().length > 0; return !!data.tenantTown && data.tenantTown.trim().length > 0;
} }
return true; return true;
@@ -112,7 +111,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
const validatedFields = FormSchema(t).safeParse({ const validatedFields = FormSchema(t).safeParse({
locationName: formData.get('locationName'), locationName: formData.get('locationName'),
generateTenantCode: formData.get('generateTenantCode') === 'on', tenantPaymentMethod: formData.get('tenantPaymentMethod') as "none" | "iban" | "revolut" | undefined,
tenantName: formData.get('tenantName') || null, tenantName: formData.get('tenantName') || null,
tenantStreet: formData.get('tenantStreet') || null, tenantStreet: formData.get('tenantStreet') || null,
tenantTown: formData.get('tenantTown') || null, tenantTown: formData.get('tenantTown') || null,
@@ -136,7 +135,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
const { const {
locationName, locationName,
generateTenantCode, tenantPaymentMethod,
tenantName, tenantName,
tenantStreet, tenantStreet,
tenantTown, tenantTown,
@@ -178,7 +177,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{ {
$set: { $set: {
name: locationName, name: locationName,
generateTenantCode: generateTenantCode || false, tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null, tenantName: tenantName || null,
tenantStreet: tenantStreet || null, tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
@@ -208,7 +207,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{ {
$set: { $set: {
name: locationName, name: locationName,
generateTenantCode: generateTenantCode || false, tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null, tenantName: tenantName || null,
tenantStreet: tenantStreet || null, tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
@@ -231,7 +230,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{ {
$set: { $set: {
name: locationName, name: locationName,
generateTenantCode: generateTenantCode || false, tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null, tenantName: tenantName || null,
tenantStreet: tenantStreet || null, tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
@@ -253,7 +252,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
userEmail, userEmail,
name: locationName, name: locationName,
notes: null, notes: null,
generateTenantCode: generateTenantCode || false, tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null, tenantName: tenantName || null,
tenantStreet: tenantStreet || null, tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
@@ -327,7 +326,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
userEmail, userEmail,
name: locationName, name: locationName,
notes: null, notes: null,
generateTenantCode: generateTenantCode || false, tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null, tenantName: tenantName || null,
tenantStreet: tenantStreet || null, tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null, tenantTown: tenantTown || null,

View File

@@ -19,7 +19,7 @@ export type State = {
ownerTown?: string[]; ownerTown?: string[];
ownerIBAN?: string[]; ownerIBAN?: string[];
currency?: string[]; currency?: string[];
show2dCodeInMonthlyStatement?: string[]; ownerRevolutProfileName?: string[];
}; };
message?: string | null; message?: string | null;
success?: boolean; success?: boolean;
@@ -29,6 +29,8 @@ export type State = {
* Schema for validating user settings form fields * Schema for validating user settings form fields
*/ */
const FormSchema = (t: IntlTemplateFn) => z.object({ const FormSchema = (t: IntlTemplateFn) => z.object({
currency: z.string().optional(),
enableIbanPayment: z.boolean().optional(),
ownerName: z.string().max(25).optional(), ownerName: z.string().max(25).optional(),
ownerStreet: z.string().max(25).optional(), ownerStreet: z.string().max(25).optional(),
ownerTown: z.string().max(27).optional(), ownerTown: z.string().max(27).optional(),
@@ -43,11 +45,11 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
}, },
{ message: t("owner-iban-invalid") } { message: t("owner-iban-invalid") }
), ),
currency: z.string().optional(), enableRevolutPayment: z.boolean().optional(),
show2dCodeInMonthlyStatement: z.boolean().optional().nullable(), ownerRevolutProfileName: z.string().max(25).optional(),
}) })
.refine((data) => { .refine((data) => {
if (data.show2dCodeInMonthlyStatement) { if (data.enableIbanPayment) {
return !!data.ownerName && data.ownerName.trim().length > 0; return !!data.ownerName && data.ownerName.trim().length > 0;
} }
return true; return true;
@@ -56,7 +58,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerName"], path: ["ownerName"],
}) })
.refine((data) => { .refine((data) => {
if (data.show2dCodeInMonthlyStatement) { if (data.enableIbanPayment) {
return !!data.ownerStreet && data.ownerStreet.trim().length > 0; return !!data.ownerStreet && data.ownerStreet.trim().length > 0;
} }
return true; return true;
@@ -65,7 +67,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerStreet"], path: ["ownerStreet"],
}) })
.refine((data) => { .refine((data) => {
if (data.show2dCodeInMonthlyStatement) { if (data.enableIbanPayment) {
return !!data.ownerTown && data.ownerTown.trim().length > 0; return !!data.ownerTown && data.ownerTown.trim().length > 0;
} }
return true; return true;
@@ -74,7 +76,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerTown"], path: ["ownerTown"],
}) })
.refine((data) => { .refine((data) => {
if (data.show2dCodeInMonthlyStatement) { if (data.enableIbanPayment) {
if (!data.ownerIBAN || data.ownerIBAN.trim().length === 0) { if (!data.ownerIBAN || data.ownerIBAN.trim().length === 0) {
return false; return false;
} }
@@ -88,13 +90,33 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerIBAN"], path: ["ownerIBAN"],
}) })
.refine((data) => { .refine((data) => {
if (data.show2dCodeInMonthlyStatement) { if (data.enableIbanPayment) {
return !!data.currency && data.currency.trim().length > 0; return !!data.currency && data.currency.trim().length > 0;
} }
return true; return true;
}, { }, {
message: t("currency-required"), message: t("currency-required"),
path: ["currency"], path: ["currency"],
})
.refine((data) => {
if (data.enableRevolutPayment) {
return !!data.ownerRevolutProfileName && data.ownerRevolutProfileName.trim().length > 0;
}
return true;
}, {
message: t("owner-revolut-profile-required"),
path: ["ownerRevolutProfileName"],
})
.refine((data) => {
if (data.enableRevolutPayment && data.ownerRevolutProfileName) {
const profileName = data.ownerRevolutProfileName.trim();
// Must start with @ and contain only English letters and numbers
return /^@[a-zA-Z0-9]+$/.test(profileName);
}
return true;
}, {
message: t("owner-revolut-profile-invalid"),
path: ["ownerRevolutProfileName"],
}); });
/** /**
@@ -141,7 +163,9 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS
ownerTown: formData.get('ownerTown') || undefined, ownerTown: formData.get('ownerTown') || undefined,
ownerIBAN: formData.get('ownerIBAN') || undefined, ownerIBAN: formData.get('ownerIBAN') || undefined,
currency: formData.get('currency') || undefined, currency: formData.get('currency') || undefined,
show2dCodeInMonthlyStatement: formData.get('generateTenantCode') === 'on', enableIbanPayment: formData.get('enableIbanPayment') === 'on' ? true : false,
enableRevolutPayment: formData.get('enableRevolutPayment') === 'on' ? true : false,
ownerRevolutProfileName: formData.get('ownerRevolutProfileName') || undefined,
}); });
// If form validation fails, return errors early. Otherwise, continue... // If form validation fails, return errors early. Otherwise, continue...
@@ -153,7 +177,7 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS
}; };
} }
const { ownerName, ownerStreet, ownerTown, ownerIBAN, currency, show2dCodeInMonthlyStatement } = validatedFields.data; const { enableIbanPayment, ownerName, ownerStreet, ownerTown, ownerIBAN, currency, enableRevolutPayment, ownerRevolutProfileName } = validatedFields.data;
// Normalize IBAN: remove spaces and convert to uppercase // Normalize IBAN: remove spaces and convert to uppercase
const normalizedOwnerIBAN = ownerIBAN ? ownerIBAN.replace(/\s/g, '').toUpperCase() : null; const normalizedOwnerIBAN = ownerIBAN ? ownerIBAN.replace(/\s/g, '').toUpperCase() : null;
@@ -164,12 +188,14 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS
const userSettings: UserSettings = { const userSettings: UserSettings = {
userId, userId,
ownerName: ownerName || null, enableIbanPayment: enableIbanPayment ?? false,
ownerStreet: ownerStreet || null, ownerName: ownerName ?? undefined,
ownerTown: ownerTown || null, ownerStreet: ownerStreet ?? undefined,
ownerTown: ownerTown ?? undefined,
ownerIBAN: normalizedOwnerIBAN, ownerIBAN: normalizedOwnerIBAN,
currency: currency || null, currency: currency ?? undefined,
show2dCodeInMonthlyStatement: show2dCodeInMonthlyStatement ?? false, enableRevolutPayment: enableRevolutPayment ?? false,
ownerRevolutProfileName: ownerRevolutProfileName ?? undefined,
}; };
await dbClient.collection<UserSettings>("userSettings") await dbClient.collection<UserSettings>("userSettings")

View File

@@ -18,6 +18,8 @@ export interface YearMonth {
export interface UserSettings { export interface UserSettings {
/** user's ID */ /** user's ID */
userId: string; userId: string;
/** whether enableshow IBAN payment instructions in monthly statement */
enableIbanPayment?: boolean | null;
/** owner name */ /** owner name */
ownerName?: string | null; ownerName?: string | null;
/** owner street */ /** owner street */
@@ -28,8 +30,10 @@ export interface UserSettings {
ownerIBAN?: string | null; ownerIBAN?: string | null;
/** currency (ISO 4217) */ /** currency (ISO 4217) */
currency?: string | null; currency?: string | null;
/** whether to show 2D code in monthly statement */ /** whether to enable Revolut payment instructions in monthly statement */
show2dCodeInMonthlyStatement?: boolean | null; enableRevolutPayment?: boolean | null;
/** owner Revolut payment link */
ownerRevolutProfileName?: string | null;
}; };
/** bill object in the form returned by MongoDB */ /** bill object in the form returned by MongoDB */
@@ -47,8 +51,10 @@ export interface BillingLocation {
bills: Bill[]; bills: Bill[];
/** (optional) notes */ /** (optional) notes */
notes: string|null; notes: string|null;
/** (optional) whether to generate 2D code for tenant */
generateTenantCode?: boolean | null; /** (optional) method for showing payment instructions to tenant */
tenantPaymentMethod?: "none" | "iban" | "revolut" | null;
/** (optional) tenant name */ /** (optional) tenant name */
tenantName?: string | null; tenantName?: string | null;
/** (optional) tenant street */ /** (optional) tenant street */

View File

@@ -2,7 +2,7 @@
import { TrashIcon } from "@heroicons/react/24/outline"; import { TrashIcon } from "@heroicons/react/24/outline";
import { FC, useState } from "react"; import { FC, useState } from "react";
import { BillingLocation, YearMonth } from "../lib/db-types"; import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types";
import { updateOrAddLocation } from "../lib/actions/locationActions"; import { updateOrAddLocation } from "../lib/actions/locationActions";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import Link from "next/link"; import Link from "next/link";
@@ -13,46 +13,44 @@ export type LocationEditFormProps = {
/** location which should be edited */ /** location which should be edited */
location: BillingLocation, location: BillingLocation,
/** year adn month at a new billing location should be assigned */ /** year adn month at a new billing location should be assigned */
yearMonth?: undefined yearMonth?: undefined,
/** user settings for payment configuration */
userSettings: UserSettings | null
} | { } | {
/** location which should be edited */ /** location which should be edited */
location?: undefined, location?: undefined,
/** year adn month at a new billing location should be assigned */ /** year adn month at a new billing location should be assigned */
yearMonth: YearMonth yearMonth: YearMonth,
/** user settings for payment configuration */
userSettings: UserSettings | null
} }
export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth }) => { export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth, userSettings }) => {
const initialState = { message: null, errors: {} }; const initialState = { message: null, errors: {} };
const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth); const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth);
const [state, dispatch] = useFormState(handleAction, initialState); const [state, dispatch] = useFormState(handleAction, initialState);
const t = useTranslations("location-edit-form"); const t = useTranslations("location-edit-form");
const locale = useLocale(); const locale = useLocale();
// Track whether to generate 2D code for tenant (use persisted value from database)
const [generateTenantCode, setGenerateTenantCode] = useState(
location?.generateTenantCode ?? false
);
// Track whether to automatically notify tenant (use persisted value from database)
const [autoBillFwd, setautoBillFwd] = useState(
location?.autoBillFwd ?? false
);
// Track whether to automatically send rent notification (use persisted value from database)
const [rentDueNotification, setrentDueNotification] = useState(
location?.rentDueNotification ?? false
);
// Track tenant field values for real-time validation // Track tenant field values for real-time validation
const [tenantFields, setTenantFields] = useState({ const [formValues, setFormValues] = useState({
locationName: location?.name ?? "",
tenantName: location?.tenantName ?? "", tenantName: location?.tenantName ?? "",
tenantStreet: location?.tenantStreet ?? "", tenantStreet: location?.tenantStreet ?? "",
tenantTown: location?.tenantTown ?? "", tenantTown: location?.tenantTown ?? "",
tenantEmail: location?.tenantEmail ?? "", tenantEmail: location?.tenantEmail ?? "",
tenantPaymentMethod: location?.tenantPaymentMethod ?? "none",
autoBillFwd: location?.autoBillFwd ?? false,
billFwdStrategy: location?.billFwdStrategy ?? "when-payed",
rentDueNotification: location?.rentDueNotification ?? false,
rentAmount: location?.rentAmount ?? "",
rentDueDay: location?.rentDueDay ?? 1,
}); });
const handleTenantFieldChange = (field: keyof typeof tenantFields, value: string) => { const handleInputChange = (field: keyof typeof formValues, value: string | boolean | number) => {
setTenantFields(prev => ({ ...prev, [field]: value })); setFormValues(prev => ({ ...prev, [field]: value }));
}; };
let { year, month } = location ? location.yearMonth : yearMonth; let { year, month } = location ? location.yearMonth : yearMonth;
@@ -69,7 +67,14 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
} }
<fieldset className="fieldset mt-2 p-2"> <fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend font-semibold uppercase">{t("location-name-legend")}</legend> <legend className="fieldset-legend font-semibold uppercase">{t("location-name-legend")}</legend>
<input id="locationName" name="locationName" type="text" placeholder={t("location-name-placeholder")} className="input input-bordered w-full placeholder:text-gray-600" defaultValue={location?.name ?? ""} /> <input id="locationName"
name="locationName"
type="text"
placeholder={t("location-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
value={formValues.locationName}
onChange={(e) => handleInputChange("locationName", e.target.value)}
/>
<div id="status-error" aria-live="polite" aria-atomic="true"> <div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.locationName && {state.errors?.locationName &&
state.errors.locationName.map((error: string) => ( state.errors.locationName.map((error: string) => (
@@ -79,38 +84,54 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
))} ))}
</div> </div>
</fieldset> </fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("tenant-2d-code-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-info")}</InfoBox>
<fieldset className="fieldset"> <fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<label className="label cursor-pointer justify-start gap-3"> <legend className="fieldset-legend font-semibold uppercase">{t("tenant-payment-instructions-legend")}</legend>
<input
type="checkbox" <InfoBox className="p-1 pt-0 mb-1">{t("tenant-payment-instructions-code-info")}</InfoBox>
name="generateTenantCode"
className="toggle toggle-primary" <fieldset className="fieldset mt-2 p-2">
checked={generateTenantCode} <legend className="fieldset-legend">{t("tenant-payment-instructions-method--legend")}</legend>
onChange={(e) => setGenerateTenantCode(e.target.checked)} <select
/> value={(!userSettings?.enableIbanPayment && !userSettings?.enableRevolutPayment) ? "none" : formValues.tenantPaymentMethod}
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend> className="select input-bordered w-full"
</label> name="tenantPaymentMethod"
onChange={(e) => handleInputChange("tenantPaymentMethod", e.target.value)}
>
<option value="none">{t("tenant-payment-instructions-method--none")}</option>
<option value="iban" disabled={!userSettings?.enableIbanPayment}>
{
userSettings?.enableIbanPayment ?
t("tenant-payment-instructions-method--iban") :
t("tenant-payment-instructions-method--iban-disabled")
}
</option>
<option value="revolut" disabled={!userSettings?.enableRevolutPayment}>
{
userSettings?.enableRevolutPayment ?
t("tenant-payment-instructions-method--revolut") :
t("tenant-payment-instructions-method--revolut-disabled")
}
</option>
</select>
</fieldset> </fieldset>
{generateTenantCode && ( { formValues.tenantPaymentMethod === "iban" && userSettings?.enableIbanPayment ? (
<> <>
<div className="divider mt-4 mb-2 font-bold uppercase">{t("iban-payment--form-title")}</div>
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">{t("tenant-name-label")}</span> <span className="label-text">{t("iban-payment--tenant-name-label")}</span>
</label> </label>
<input <input
id="tenantName" id="tenantName"
name="tenantName" name="tenantName"
type="text" type="text"
maxLength={30} maxLength={30}
placeholder={t("tenant-name-placeholder")} placeholder={t("iban-payment--tenant-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantName ?? ""} defaultValue={formValues.tenantName}
onChange={(e) => handleTenantFieldChange("tenantName", e.target.value)} onChange={(e) => handleInputChange("tenantName", e.target.value)}
/> />
<div id="tenantName-error" aria-live="polite" aria-atomic="true"> <div id="tenantName-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantName && {state.errors?.tenantName &&
@@ -124,17 +145,17 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">{t("tenant-street-label")}</span> <span className="label-text">{t("iban-payment--tenant-street-label")}</span>
</label> </label>
<input <input
id="tenantStreet" id="tenantStreet"
name="tenantStreet" name="tenantStreet"
type="text" type="text"
maxLength={27} maxLength={27}
placeholder={t("tenant-street-placeholder")} placeholder={t("iban-payment--tenant-street-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantStreet ?? ""} defaultValue={formValues.tenantStreet}
onChange={(e) => handleTenantFieldChange("tenantStreet", e.target.value)} onChange={(e) => handleInputChange("tenantStreet", e.target.value)}
/> />
<div id="tenantStreet-error" aria-live="polite" aria-atomic="true"> <div id="tenantStreet-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantStreet && {state.errors?.tenantStreet &&
@@ -148,17 +169,17 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
<div className="form-control w-full mb-4"> <div className="form-control w-full mb-4">
<label className="label"> <label className="label">
<span className="label-text">{t("tenant-town-label")}</span> <span className="label-text">{t("iban-payment--tenant-town-label")}</span>
</label> </label>
<input <input
id="tenantTown" id="tenantTown"
name="tenantTown" name="tenantTown"
type="text" type="text"
maxLength={27} maxLength={27}
placeholder={t("tenant-town-placeholder")} placeholder={t("iban-payment--tenant-town-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantTown ?? ""} defaultValue={formValues.tenantTown}
onChange={(e) => handleTenantFieldChange("tenantTown", e.target.value)} onChange={(e) => handleInputChange("tenantTown", e.target.value)}
/> />
<div id="tenantTown-error" aria-live="polite" aria-atomic="true"> <div id="tenantTown-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantTown && {state.errors?.tenantTown &&
@@ -169,12 +190,32 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
))} ))}
</div> </div>
</div> </div>
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-note")}</InfoBox>
</> </>
)} ) : // ELSE include hidden inputs to preserve existing values
<>
<input
id="tenantName"
name="tenantName"
type="hidden"
maxLength={30}
defaultValue={formValues.tenantName}
/>
<input
id="tenantStreet"
name="tenantStreet"
type="hidden"
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantStreet}
/>
<input
id="tenantTown"
name="tenantTown"
type="hidden"
defaultValue={formValues.tenantTown}
/>
</>
}
</fieldset> </fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4"> <fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("auto-utility-bill-forwarding-legend")}</legend> <legend className="fieldset-legend font-semibold uppercase">{t("auto-utility-bill-forwarding-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("auto-utility-bill-forwarding-info")}</InfoBox> <InfoBox className="p-1 mb-1">{t("auto-utility-bill-forwarding-info")}</InfoBox>
@@ -186,17 +227,17 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
type="checkbox" type="checkbox"
name="autoBillFwd" name="autoBillFwd"
className="toggle toggle-primary" className="toggle toggle-primary"
checked={autoBillFwd} checked={formValues.autoBillFwd}
onChange={(e) => setautoBillFwd(e.target.checked)} onChange={(e) => handleInputChange("autoBillFwd", e.target.checked)}
/> />
<legend className="fieldset-legend">{t("auto-utility-bill-forwarding-toggle-label")}</legend> <legend className="fieldset-legend">{t("auto-utility-bill-forwarding-toggle-label")}</legend>
</label> </label>
</fieldset> </fieldset>
{autoBillFwd && ( {formValues.autoBillFwd && (
<fieldset className="fieldset mt-2 p-2"> <fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("utility-bill-forwarding-strategy-label")}</legend> <legend className="fieldset-legend">{t("utility-bill-forwarding-strategy-label")}</legend>
<select defaultValue={location?.billFwdStrategy ?? "when-payed"} className="select input-bordered w-full" name="billFwdStrategy"> <select defaultValue={formValues.billFwdStrategy} className="select input-bordered w-full" name="billFwdStrategy">
<option value="when-payed">{t("utility-bill-forwarding-when-payed")}</option> <option value="when-payed">{t("utility-bill-forwarding-when-payed")}</option>
<option value="when-attached">{t("utility-bill-forwarding-when-attached")}</option> <option value="when-attached">{t("utility-bill-forwarding-when-attached")}</option>
</select> </select>
@@ -215,18 +256,22 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
type="checkbox" type="checkbox"
name="rentDueNotification" name="rentDueNotification"
className="toggle toggle-primary" className="toggle toggle-primary"
checked={rentDueNotification} checked={formValues.rentDueNotification}
onChange={(e) => setrentDueNotification(e.target.checked)} onChange={(e) => handleInputChange("rentDueNotification", e.target.checked)}
/> />
<legend className="fieldset-legend">{t("auto-rent-notification-toggle-label")}</legend> <legend className="fieldset-legend">{t("auto-rent-notification-toggle-label")}</legend>
</label> </label>
</fieldset> </fieldset>
{rentDueNotification && ( {formValues.rentDueNotification && (
<> <>
<fieldset className="fieldset mt-2 p-2"> <fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("rent-due-day-label")}</legend> <legend className="fieldset-legend">{t("rent-due-day-label")}</legend>
<select defaultValue={location?.rentDueDay ?? 1} className="select input-bordered w-full" name="rentDueDay"> <select defaultValue={formValues.rentDueDay}
className="select input-bordered w-full"
name="rentDueDay"
onChange={(e) => handleInputChange("rentDueDay", parseInt(e.target.value,10))
}>
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => ( {Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}>{day}</option> <option key={day} value={day}>{day}</option>
))} ))}
@@ -242,7 +287,8 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
step="0.01" step="0.01"
placeholder={t("rent-amount-placeholder")} placeholder={t("rent-amount-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600 text-right" className="input input-bordered w-full placeholder:text-gray-600 text-right"
defaultValue={location?.rentAmount ?? ""} defaultValue={formValues.rentAmount}
onChange={(e) => handleInputChange("rentAmount", parseFloat(e.target.value))}
/> />
<div id="rentAmount-error" aria-live="polite" aria-atomic="true"> <div id="rentAmount-error" aria-live="polite" aria-atomic="true">
{state.errors?.rentAmount && {state.errors?.rentAmount &&
@@ -257,7 +303,7 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
)} )}
</fieldset> </fieldset>
{(autoBillFwd || rentDueNotification) && ( {(formValues.autoBillFwd || formValues.rentDueNotification) && (
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4"> <fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("tenant-email-legend")}</legend> <legend className="fieldset-legend font-semibold uppercase">{t("tenant-email-legend")}</legend>
<input <input
@@ -266,8 +312,8 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
type="email" type="email"
placeholder={t("tenant-email-placeholder")} placeholder={t("tenant-email-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantEmail ?? ""} defaultValue={formValues.tenantEmail}
onChange={(e) => handleTenantFieldChange("tenantEmail", e.target.value)} onChange={(e) => handleInputChange("tenantEmail", e.target.value)}
/> />
<div id="tenantEmail-error" aria-live="polite" aria-atomic="true"> <div id="tenantEmail-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantEmail && {state.errors?.tenantEmail &&

7
app/ui/NoteBox.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { FC, ReactNode } from "react";
export const NoteBox: FC<{ children: ReactNode, className?: string }> = ({ children, className }) =>
<div className={`alert max-w-md flex flex-row items-start gap-[.8rem] ml-1 ${className}`}>
<span className="w-6 h-6 text-xl"></span>
<span className="text-left">{children}</span>
</div>

View File

@@ -9,6 +9,8 @@ import Link from "next/link";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import { formatIban } from "../lib/formatStrings"; import { formatIban } from "../lib/formatStrings";
import { InfoBox } from "./InfoBox"; import { InfoBox } from "./InfoBox";
import { NoteBox } from "./NoteBox";
import { LinkIcon } from "@heroicons/react/24/outline";
export type UserSettingsFormProps = { export type UserSettingsFormProps = {
userSettings: UserSettings | null; userSettings: UserSettings | null;
@@ -27,26 +29,23 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
// Track current form values for real-time validation // Track current form values for real-time validation
const [formValues, setFormValues] = useState({ const [formValues, setFormValues] = useState({
enableIbanPayment: userSettings?.enableIbanPayment ?? false,
ownerName: userSettings?.ownerName ?? "", ownerName: userSettings?.ownerName ?? "",
ownerStreet: userSettings?.ownerStreet ?? "", ownerStreet: userSettings?.ownerStreet ?? "",
ownerTown: userSettings?.ownerTown ?? "", ownerTown: userSettings?.ownerTown ?? "",
ownerIBAN: formatIban(userSettings?.ownerIBAN) ?? "", ownerIBAN: formatIban(userSettings?.ownerIBAN) ?? "",
currency: userSettings?.currency ?? "EUR", currency: userSettings?.currency ?? "EUR",
enableRevolutPayment: userSettings?.enableRevolutPayment ?? false,
ownerRevolutProfileName: userSettings?.ownerRevolutProfileName ?? "",
}); });
const handleInputChange = (field: keyof typeof formValues, value: string) => { // https://revolut.me/aderezic?currency=EUR&amount=70000
const handleInputChange = (field: keyof typeof formValues, value: string | boolean) => {
setFormValues(prev => ({ ...prev, [field]: value })); setFormValues(prev => ({ ...prev, [field]: value }));
}; };
// Check if any required field is missing (clean IBAN of spaces for validation)
const cleanedOwnerIBAN = formValues.ownerIBAN.replace(/\s/g, '');
const hasMissingData = !formValues.ownerName || !formValues.ownerStreet || !formValues.ownerTown || !cleanedOwnerIBAN || !formValues.currency;
// Track whether to generate 2D code for tenant (use persisted value from database)
const [show2dCodeInMonthlyStatement, setShow2dCodeInMonthlyStatement] = useState(
userSettings?.show2dCodeInMonthlyStatement ?? false
);
return ( return (
<> <>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pt-1 pb-2 mt-4"> <fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pt-1 pb-2 mt-4">
@@ -60,7 +59,7 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
id="currency" id="currency"
name="currency" name="currency"
className="select select-bordered w-full" className="select select-bordered w-full"
defaultValue={userSettings?.currency ?? "EUR"} defaultValue={formValues.currency}
onChange={(e) => handleInputChange("currency", e.target.value)} onChange={(e) => handleInputChange("currency", e.target.value)}
disabled={pending} disabled={pending}
> >
@@ -110,38 +109,40 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("tenant-2d-code-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("info-box-message")}</InfoBox> <fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("iban-payment-instructions--legend")}</legend>
<InfoBox className="p-1 mb-1">{t("iban-payment-instructions--intro-message")}</InfoBox>
<fieldset className="fieldset"> <fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3"> <label className="label cursor-pointer justify-start gap-3">
<input <input
type="checkbox" type="checkbox"
name="generateTenantCode" name="enableIbanPayment"
className="toggle toggle-primary" className="toggle toggle-primary"
checked={show2dCodeInMonthlyStatement} checked={formValues.enableIbanPayment}
onChange={(e) => setShow2dCodeInMonthlyStatement(e.target.checked)} onChange={(e) => handleInputChange("enableIbanPayment", e.target.checked)}
/> />
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend> <legend className="fieldset-legend">{t("iban-payment-instructions--toggle-label")}</legend>
</label> </label>
</fieldset> </fieldset>
{show2dCodeInMonthlyStatement && ( { formValues.enableIbanPayment ? (
<> <>
<div className="divider mt-2 mb-2 font-bold uppercase">{t("iban-form-title")}</div>
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">{t("owner-name-label")}</span> <span className="label-text">{t("iban-owner-name-label")}</span>
</label> </label>
<input <input
id="ownerName" id="ownerName"
name="ownerName" name="ownerName"
type="text" type="text"
maxLength={25} maxLength={25}
placeholder={t("owner-name-placeholder")} placeholder={t("iban-owner-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.ownerName ?? ""} defaultValue={formValues.ownerName}
onChange={(e) => handleInputChange("ownerName", e.target.value)} onChange={(e) => handleInputChange("ownerName", e.target.value)}
disabled={pending} disabled={pending}
/> />
@@ -157,16 +158,16 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">{t("owner-street-label")} </span> <span className="label-text">{t("iban-owner-street-label")} </span>
</label> </label>
<input <input
id="ownerStreet" id="ownerStreet"
name="ownerStreet" name="ownerStreet"
type="text" type="text"
maxLength={25} maxLength={25}
placeholder={t("owner-street-placeholder")} placeholder={t("iban-owner-street-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.ownerStreet ?? ""} defaultValue={formValues.ownerStreet}
onChange={(e) => handleInputChange("ownerStreet", e.target.value)} onChange={(e) => handleInputChange("ownerStreet", e.target.value)}
disabled={pending} disabled={pending}
/> />
@@ -182,16 +183,16 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">{t("owner-town-label")}</span> <span className="label-text">{t("iban-owner-town-label")}</span>
</label> </label>
<input <input
id="ownerTown" id="ownerTown"
name="ownerTown" name="ownerTown"
type="text" type="text"
maxLength={27} maxLength={27}
placeholder={t("owner-town-placeholder")} placeholder={t("iban-owner-town-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.ownerTown ?? ""} defaultValue={formValues.ownerTown}
onChange={(e) => handleInputChange("ownerTown", e.target.value)} onChange={(e) => handleInputChange("ownerTown", e.target.value)}
disabled={pending} disabled={pending}
/> />
@@ -207,15 +208,15 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">{t("owner-iban-label")}</span> <span className="label-text">{t("iban-owner-iban-label")}</span>
</label> </label>
<input <input
id="ownerIBAN" id="ownerIBAN"
name="ownerIBAN" name="ownerIBAN"
type="text" type="text"
placeholder={t("owner-iban-placeholder")} placeholder={t("iban-owner-iban-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600" className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formatIban(userSettings?.ownerIBAN)} defaultValue={formValues.ownerIBAN}
onChange={(e) => handleInputChange("ownerIBAN", e.target.value)} onChange={(e) => handleInputChange("ownerIBAN", e.target.value)}
disabled={pending} disabled={pending}
/> />
@@ -229,9 +230,114 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
</div> </div>
</div> </div>
<InfoBox className="p-1 mt-1">{t("additional-notes")}</InfoBox> <NoteBox className="p-1 mt-1">{t("payment-additional-notes")}</NoteBox>
</> </>
)} ) : // ELSE include hidden inputs to preserve existing values
<>
<input
id="ownerName"
name="ownerName"
type="hidden"
value={formValues.ownerName}
/>
<input
id="ownerStreet"
name="ownerStreet"
type="hidden"
value={formValues.ownerStreet}
/>
<input
id="ownerTown"
name="ownerTown"
type="hidden"
defaultValue={formValues.ownerTown}
/>
<input
id="ownerIBAN"
name="ownerIBAN"
type="hidden"
defaultValue={formValues.ownerIBAN}
/>
</>
}
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("revolut-payment-instructions--legend")}</legend>
<InfoBox className="p-1 mb-1">{t("revolut-payment-instructions--intro-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enableRevolutPayment"
className="toggle toggle-primary"
checked={formValues.enableRevolutPayment}
onChange={(e) => handleInputChange("enableRevolutPayment", e.target.checked)}
/>
<legend className="fieldset-legend">{t("revolut-payment-instructions--toggle-label")}</legend>
</label>
</fieldset>
{ formValues.enableRevolutPayment ? (
<>
<div className="divider mt-2 mb-2 font-bold uppercase">{t("revolut-form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("revolut-profile-label")}</span>
</label>
<input
id="ownerRevolutProfileName"
name="ownerRevolutProfileName"
type="text"
maxLength={25}
placeholder={t("revolut-profile-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerRevolutProfileName}
onChange={(e) => handleInputChange("ownerRevolutProfileName", e.target.value)}
disabled={pending}
/>
<label className="label">
<span className="label-text-alt text-gray-500 max-w-[25rem]">{t("revolut-profile-tooltip")}</span>
</label>
<div id="ownerRevolutProfileName-error" aria-live="polite" aria-atomic="true">
{errors?.ownerRevolutProfileName &&
errors.ownerRevolutProfileName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
{
!errors?.ownerRevolutProfileName && formValues.ownerRevolutProfileName.length > 5 ? (
<p className="p-2 text-center">
{t("revolut-profile--test-link-label")} {' '}
<LinkIcon className="h-[1.2em] w-[1.2em] inline-block ml-1 mr-1"/>
<Link
href={`https://revolut.me/${formValues.ownerRevolutProfileName?.replace('@', '')}?amount=100&currency=${formValues.currency}`}
target="_blank"
className="underline"
>
{t("revolut-profile--test-link-text")}
</Link>
</p>
) : null
}
</div>
<NoteBox className="p-1 mt-1">{t("payment-additional-notes")}</NoteBox>
</>
)
: // ELSE include hidden input to preserve existing value
<>
<input
id="ownerRevolutProfileName"
name="ownerRevolutProfileName"
type="hidden"
value={formValues.ownerRevolutProfileName}
/>
</>
}
</fieldset> </fieldset>
<div id="general-error" aria-live="polite" aria-atomic="true"> <div id="general-error" aria-live="polite" aria-atomic="true">

View File

@@ -9,8 +9,9 @@ import { ViewBillBadge } from "./ViewBillBadge";
import { Pdf417Barcode } from "./Pdf417Barcode"; import { Pdf417Barcode } from "./Pdf417Barcode";
import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder"; import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link"; import Link from "next/link";
import { DocumentIcon } from "@heroicons/react/24/outline"; import { DocumentIcon, LinkIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions"; import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
import QRCode from "react-qr-code";
export interface ViewLocationCardProps { export interface ViewLocationCardProps {
location: BillingLocation; location: BillingLocation;
@@ -27,7 +28,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
tenantName, tenantName,
tenantStreet, tenantStreet,
tenantTown, tenantTown,
generateTenantCode, tenantPaymentMethod,
// NOTE: only the fileName is projected from the DB to reduce data transfer // NOTE: only the fileName is projected from the DB to reduce data transfer
utilBillsProofOfPaymentAttachment, utilBillsProofOfPaymentAttachment,
utilBillsProofOfPaymentUploadedAt, utilBillsProofOfPaymentUploadedAt,
@@ -79,7 +80,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
const { hub3aText, paymentParams } = useMemo(() => { const { hub3aText, paymentParams } = useMemo(() => {
if(!userSettings?.show2dCodeInMonthlyStatement || !generateTenantCode) { if(!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") {
return { return {
hub3aText: "", hub3aText: "",
paymentParams: {} as PaymentParams paymentParams: {} as PaymentParams
@@ -107,7 +108,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
hub3aText: EncodePayment(paymentParams), hub3aText: EncodePayment(paymentParams),
paymentParams paymentParams
}); });
}, [userSettings?.show2dCodeInMonthlyStatement, generateTenantCode, locationName, tenantName, tenantStreet, tenantTown, userSettings, monthlyExpense, yearMonth]); }, []);
return( return(
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1"> <div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
@@ -126,7 +127,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
: null : null
} }
{ {
userSettings?.show2dCodeInMonthlyStatement && generateTenantCode ? userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ?
<> <>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p> <p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<ul className="ml-4 mb-3"> <ul className="ml-4 mb-3">
@@ -145,6 +146,30 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
</> </>
: null : null
} }
{
userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => {
const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(monthlyExpense).toFixed(0)}&currency=${userSettings.currency}`;
return (
<>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<div className="flex justify-center">
<QRCode value={revolutPaymentUrl} size={200} className="p-4 bg-white border border-gray-300 rounded-box" />
</div>
<p className="text-center mt-1 mb-3">
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]"/>
<Link
href={revolutPaymentUrl}
target="_blank"
className="underline"
>
{t("revolut-link-text")}
</Link>
</p>
</>
);
})()
: null
}
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2"> <fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend> <legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{ {

View File

@@ -70,7 +70,8 @@
"payment-purpose-code-label": "Purpose code:", "payment-purpose-code-label": "Purpose code:",
"payment-description-label": "Payment description:", "payment-description-label": "Payment description:",
"upload-proof-of-payment-legend": "Proof of payment", "upload-proof-of-payment-legend": "Proof of payment",
"upload-proof-of-payment-label": "Here you can upload proof of payment:" "upload-proof-of-payment-label": "Here you can upload proof of payment:",
"revolut-link-text": "Pay with Revolut"
}, },
"month-card": { "month-card": {
"payed-total-label": "Total monthly expenditure:", "payed-total-label": "Total monthly expenditure:",
@@ -141,23 +142,33 @@
"location-name-legend": "Realestate name", "location-name-legend": "Realestate name",
"location-name-placeholder": "enter realestate name", "location-name-placeholder": "enter realestate name",
"notes-placeholder": "notes", "notes-placeholder": "notes",
"tenant-2d-code-legend": "PAYMENT INSTRUCTIONS",
"tenant-2d-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.", "tenant-payment-instructions-legend": "PAYMENT INSTRUCTIONS",
"tenant-2d-code-toggle-label": "show payment instructions to the tenant", "tenant-payment-instructions-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.",
"tenant-2d-code-note": "IMPORTANT: for this to work you will also need to go into app settings and enter your name and IBAN.",
"tenant-name-label": "Tenant First and Last Name", "tenant-payment-instructions-method--legend": "Show payment instructions to tenant:",
"tenant-name-placeholder": "enter tenant's first and last name", "tenant-payment-instructions-method--none": "do not show payment instructions",
"tenant-street-label": "Tenant Street and House Number", "tenant-payment-instructions-method--iban": "payment via IBAN",
"tenant-street-placeholder": "enter tenant's street", "tenant-payment-instructions-method--iban-disabled": "payment via IBAN - disabled in app settings",
"tenant-town-label": "Tenant Postal Code and Town", "tenant-payment-instructions-method--revolut": "payment via Revolut",
"tenant-town-placeholder": "enter tenant's town", "tenant-payment-instructions-method--revolut-disabled": "payment via Revolut - disabled in app settings",
"auto-utility-bill-forwarding-legend": "AUTOMATIC UTILITY BILL FORWARDING",
"iban-payment--tenant-name-label": "Tenant First and Last Name",
"iban-payment--tenant-name-placeholder": "enter tenant's first and last name",
"iban-payment--tenant-street-label": "Tenant Street and House Number",
"iban-payment--tenant-street-placeholder": "enter tenant's street",
"iban-payment--tenant-town-label": "Tenant Postal Code and Town",
"iban-payment--tenant-town-placeholder": "enter tenant's town",
"auto-utility-bill-forwarding-legend": "Automatic utility bill forwarding",
"auto-utility-bill-forwarding-info": "This option enables automatic forwarding of utility bills to the tenant via email according to the selected forwarding strategy.", "auto-utility-bill-forwarding-info": "This option enables automatic forwarding of utility bills to the tenant via email according to the selected forwarding strategy.",
"auto-utility-bill-forwarding-toggle-label": "forward utility bills", "auto-utility-bill-forwarding-toggle-label": "forward utility bills",
"utility-bill-forwarding-strategy-label": "Forward utility bills when ...", "utility-bill-forwarding-strategy-label": "Forward utility bills when ...",
"utility-bill-forwarding-when-payed": "all items are marked as paid", "utility-bill-forwarding-when-payed": "all items are marked as paid",
"utility-bill-forwarding-when-attached": "a bill (PDF) is attached to all items", "utility-bill-forwarding-when-attached": "a bill (PDF) is attached to all items",
"auto-rent-notification-legend": "AUTOMATIC RENT NOTIFICATION", "auto-rent-notification-legend": "Automatic rent notification",
"auto-rent-notification-info": "This option enables automatic sending of monthly rent bill to the tenant via email on the specified day of the month.", "auto-rent-notification-info": "This option enables automatic sending of monthly rent bill to the tenant via email on the specified day of the month.",
"auto-rent-notification-toggle-label": "send rent notification", "auto-rent-notification-toggle-label": "send rent notification",
"rent-due-day-label": "Day of month when rent is due", "rent-due-day-label": "Day of month when rent is due",
@@ -191,17 +202,35 @@
}, },
"user-settings-form": { "user-settings-form": {
"title": "User settings", "title": "User settings",
"info-box-message": "By activating this option, a 2D barcode will be included in the monthly statement sent to the tenant, allowing them to make a direct payment to your bank account.",
"tenant-2d-code-legend": "TENANT 2D CODE", "iban-payment-instructions--legend": "Payment to Your IBAN",
"tenant-2d-code-toggle-label": "include 2D code in monthly statements", "iban-payment-instructions--intro-message": "By activating this option, payment instructions will be included in the monthly statement sent to the tenant, allowing a direct payment via IBAN to be made to your bank account.",
"owner-name-label": "Your First and Last Name", "iban-payment-instructions--toggle-label": "enable IBAN payment instructions",
"owner-name-placeholder": "enter your first and last name",
"owner-street-label": "Your Street and House Number", "iban-form-title": "Payment Information for IBAN",
"owner-street-placeholder": "enter your street and house number", "iban-owner-name-label": "Your First and Last Name",
"owner-town-label": "Your Postal Code and Town", "iban-owner-name-placeholder": "enter your first and last name",
"owner-town-placeholder": "enter your postal code and town", "iban-owner-street-label": "Your Street and House Number",
"owner-iban-label": "IBAN", "iban-owner-street-placeholder": "enter your street and house number",
"owner-iban-placeholder": "enter your IBAN", "iban-owner-town-label": "Your Postal Code and Town",
"iban-owner-town-placeholder": "enter your postal code and town",
"iban-owner-iban-label": "IBAN",
"iban-owner-iban-placeholder": "enter your IBAN for receiving payments",
"revolut-form-title": "Payment Information for Revolut",
"revolut-payment-instructions--legend": "Payment to Your Revolut Profile",
"revolut-payment-instructions--intro-message": "By activating this option, payment instructions will be included in the monthly statement sent to the tenant, allowing a direct payment via Revolut to be made to your Revolut profile.",
"revolut-payment-instructions--toggle-label": "enable Revolut payment instructions",
"revolut-profile-label": "Revolut profile name",
"revolut-profile-placeholder": "enter your Revolut profile name for receiving payments",
"revolut-profile-tooltip": "You can find your Revolut profile name in the Revolut app under your user profile. It is displayed below your name and starts with the '@' symbol (e.g., '@john123').",
"revolut-profile--test-link-label": "Test your Revolut link:",
"revolut-profile--test-link-text": "Pay with Revolut",
"payment-additional-notes": "IMPORTANT: For the payment instructions to be displayed to the tenant, you must also enable this option in the property's settings.",
"general-settings-legend": "General Settings", "general-settings-legend": "General Settings",
"currency-label": "Currency", "currency-label": "Currency",
"save-button": "Save", "save-button": "Save",
@@ -213,8 +242,9 @@
"owner-iban-required": "Valid IBAN is mandatory", "owner-iban-required": "Valid IBAN is mandatory",
"owner-iban-invalid": "Invalid IBAN format. Please enter a valid IBAN", "owner-iban-invalid": "Invalid IBAN format. Please enter a valid IBAN",
"currency-required": "Currency is mandatory", "currency-required": "Currency is mandatory",
"owner-revolut-profile-required": "Revolut profile name is mandatory",
"owner-revolut-profile-invalid": "Invalid Revolut profile format. Must start with '@' and contain only English letters and numbers (e.g., '@john123')",
"validation-failed": "Validation failed. Please check the form and try again." "validation-failed": "Validation failed. Please check the form and try again."
}, }
"additional-notes": "Note: For the 2D code to be displayed, you must enter both the tenant's first and last names in the settings of each property for which you want to use this functionality."
} }
} }

View File

@@ -70,7 +70,8 @@
"payment-purpose-code-label": "Šifra namjene:", "payment-purpose-code-label": "Šifra namjene:",
"payment-description-label": "Opis plaćanja:", "payment-description-label": "Opis plaćanja:",
"upload-proof-of-payment-legend": "Potvrda o uplati", "upload-proof-of-payment-legend": "Potvrda o uplati",
"upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:" "upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:",
"revolut-link-text": "Plati pomoću Revoluta"
}, },
"month-card": { "month-card": {
"payed-total-label": "Ukupni mjesečni trošak:", "payed-total-label": "Ukupni mjesečni trošak:",
@@ -140,25 +141,35 @@
"location-name-legend": "Realestate name", "location-name-legend": "Realestate name",
"location-name-placeholder": "unesite naziv nekretnine", "location-name-placeholder": "unesite naziv nekretnine",
"notes-placeholder": "bilješke", "notes-placeholder": "bilješke",
"tenant-2d-code-legend": "UPUTE ZA UPLATU",
"tenant-2d-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN, kao i 2D koji može skenirati.", "tenant-payment-instructions-legend": "UPUTE ZA UPLATU",
"tenant-2d-code-toggle-label": "podstanaru prikaži upute za uplatu", "tenant-payment-instructions-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN ili Revolut.",
"tenant-2d-code-note": "VAŽNO: za ovu funkcionalnost potrebno je otvoriti postavke aplikacije, te unijeti vaše ime i IBAN.",
"tenant-name-label": "Ime i prezime podstanara", "tenant-payment-instructions-method--legend": "Podstanaru prikaži upute za uplatu:",
"tenant-name-placeholder": "unesite ime i prezime podstanara", "tenant-payment-instructions-method--none": "ne prikazuj upute za uplatu",
"tenant-street-label": "Ulica podstanara i kućni broj", "tenant-payment-instructions-method--iban": "uplata na IBAN",
"tenant-street-placeholder": "unesite ulicu podstanara", "tenant-payment-instructions-method--iban-disabled": "uplata na IBAN - onemogućeno u app postavkama",
"tenant-town-label": "Poštanski broj i Grad podstanara", "tenant-payment-instructions-method--revolut": "uplata na Revolut",
"tenant-town-placeholder": "unesite poštanski broj i grad podstanara", "tenant-payment-instructions-method--revolut-disabled": "uplata na Revolut - onemogućeno u app postavkama",
"tenant-payment-instructions-method--disabled-message": "Ova opcija je nedostupna zato što nije omogućena u postavkama aplikacije.",
"iban-payment--form-title": "Informacije za uplatu na IBAN",
"iban-payment--tenant-name-label": "Ime i prezime podstanara",
"iban-payment--tenant-name-placeholder": "unesite ime i prezime podstanara",
"iban-payment--tenant-street-label": "Ulica podstanara i kućni broj",
"iban-payment--tenant-street-placeholder": "unesite ulicu podstanara",
"iban-payment--tenant-town-label": "Poštanski broj i Grad podstanara",
"iban-payment--tenant-town-placeholder": "unesite poštanski broj i grad podstanara",
"auto-utility-bill-forwarding-legend": "AUTOMATSKO PROSLJEĐIVANJE REŽIJA", "auto-utility-bill-forwarding-legend": "AUTOMATSKO PROSLJEĐIVANJE REŽIJA",
"auto-utility-bill-forwarding-info": "Ova opcija omogućuje automatsko prosljeđivanje režija podstanaru putem emaila u skladu s odabranom strategijom.", "auto-utility-bill-forwarding-info": "Ova opcija omogućuje automatsko prosljeđivanje režija podstanaru putem emaila u skladu s odabranom strategijom.",
"auto-utility-bill-forwarding-toggle-label": "proslijedi režije automatski", "auto-utility-bill-forwarding-toggle-label": "proslijedi režije automatski",
"utility-bill-forwarding-strategy-label": "Režije proslijedi kada...", "utility-bill-forwarding-strategy-label": "Režije proslijedi kada...",
"utility-bill-forwarding-when-payed": "sve stavke označim kao plaćene", "utility-bill-forwarding-when-payed": "sve stavke označim kao plaćene",
"utility-bill-forwarding-when-attached": "za sve stavke priložim račun (PDF)", "utility-bill-forwarding-when-attached": "za sve stavke priložim račun (PDF)",
"auto-rent-notification-legend": "AUTOMATSKA OBAVIJEST O NAJAMNINI", "auto-rent-notification-legend": "Automatska obavjest O najamnini",
"auto-rent-notification-info": "Ova opcija omogućuje automatsko slanje mjesečnog računa za najamninu podstanaru putem emaila na zadani dan u mjesecu.", "auto-rent-notification-info": "Ova opcija omogućuje automatsko slanje mjesečnog računa za najamninu podstanaru putem emaila na zadani dan u mjesecu.",
"auto-rent-notification-toggle-label": "pošalji obavijest o najamnini", "auto-rent-notification-toggle-label": "pošalji obavjest o najamnini",
"rent-due-day-label": "Dan u mjesecu kada dospijeva najamnina", "rent-due-day-label": "Dan u mjesecu kada dospijeva najamnina",
"rent-amount-label": "Iznos najamnine", "rent-amount-label": "Iznos najamnine",
"rent-amount-placeholder": "unesite iznos najamnine", "rent-amount-placeholder": "unesite iznos najamnine",
@@ -190,17 +201,32 @@
}, },
"user-settings-form": { "user-settings-form": {
"title": "Korisničke postavke", "title": "Korisničke postavke",
"info-box-message": "Ako uključite ovu opciji na mjesečnom obračunu koji se šalje podstanaru biti će prikazan 2D bar kod, putem kojeg će moći izvršiti izravnu uplatu na vaš bankovni račun.",
"tenant-2d-code-legend": "2D BARKOD ZA PODSTANARA", "iban-payment-instructions--legend": "Uplata na vaš IBAN",
"tenant-2d-code-toggle-label": "prikazuj 2D barkod u mjesečnom obračunu", "iban-payment-instructions--intro-message": "Aktiviranjem ove opcije, upute za uplatu bit će uključene u mjesečni izvještaj poslan podstanaru, omogućujući im da izvrše izravnu uplatu putem IBAN-a na vaš bankovni račun.",
"owner-name-label": "Vaše ime i prezime", "iban-payment-instructions--toggle-label": "uključi IBAN uplatu",
"owner-name-placeholder": "unesite svoje ime i prezime",
"owner-street-label": "Ulica i kućni broj", "iban-form-title": "Informacije za uplatu na IBAN",
"owner-street-placeholder": "unesite ulicu i kućni broj", "iban-owner-name-label": "Vaše ime i prezime",
"owner-town-label": "Poštanski broj i Grad", "iban-owner-name-placeholder": "unesite svoje ime i prezime",
"owner-town-placeholder": "unesite poštanski broj i grad", "iban-owner-street-label": "Ulica i kućni broj",
"owner-iban-label": "IBAN", "iban-owner-street-placeholder": "unesite ulicu i kućni broj",
"owner-iban-placeholder": "unesite svoj IBAN", "iban-owner-town-label": "Poštanski broj i Grad",
"iban-owner-town-placeholder": "unesite poštanski broj i grad",
"iban-owner-iban-label": "IBAN",
"iban-owner-iban-placeholder": "IBAN putem kojeg ćete primate uplate",
"revolut-payment-instructions--legend": "Uplata na vaš Revolut profil",
"revolut-payment-instructions--intro-message": "Aktiviranjem ove opcije, upute za uplatu bit će uključene u mjesečni izvještaj poslan podstanaru, omogućujući im da izvrše izravnu uplatu putem Revolut-a na vaš profil.",
"revolut-payment-instructions--toggle-label": "uključi Revolut uplatu",
"revolut-form-title": "Informacije za uplatu na Revolut",
"revolut-profile-label": "Naziv vašeg Revolut profila",
"revolut-profile-placeholder": "profil putem kojeg ćete primati uplate",
"revolut-profile-tooltip": "Naziv vašeg Revolut profila možete pronaći u aplikaciji Revolut u korisničkom profilu. Prikazan je ispod vašeg imena i prezimena - počinje sa znakom '@' (npr: '@ivan123').",
"revolut-profile--test-link-label": "Testiraj svoju Revolut poveznicu:",
"revolut-profile--test-link-text": "Plati pomoću Revoluta",
"general-settings-legend": "Opće postavke", "general-settings-legend": "Opće postavke",
"currency-label": "Valuta", "currency-label": "Valuta",
"save-button": "Spremi", "save-button": "Spremi",
@@ -212,8 +238,10 @@
"owner-iban-required": "Ispravan IBAN je obavezan", "owner-iban-required": "Ispravan IBAN je obavezan",
"owner-iban-invalid": "Neispravan IBAN format. Molimo unesite ispravan IBAN.", "owner-iban-invalid": "Neispravan IBAN format. Molimo unesite ispravan IBAN.",
"currency-required": "Valuta je obavezna", "currency-required": "Valuta je obavezna",
"owner-revolut-profile-required": "Naziv Revolut profila je obavezan",
"owner-revolut-profile-invalid": "Neispravan format Revolut profila. Mora počinjati sa '@' i sadržavati samo slova i brojeve (npr. '@ivan123')",
"validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno." "validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno."
}, },
"additional-notes": "Napomena: da bi 2D koda bio prikazan, morate unijeti i ime i prezime podstanara u postavkama svake nekretnine za koju želite koristiti ovu funkcionalnost." "payment-additional-notes": "VAŽNO: da bi upute za uplatu bile prikazane podstanaru, morate tu ovu opciju uključiti i u postavkama pripadajuće nekretnine."
} }
} }

22
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "evidencija-rezija", "name": "evidencija-rezija",
"version": "2.1.2", "version": "2.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "2.1.2", "version": "2.2.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
@@ -34,6 +34,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
"react-qr-code": "^2.0.18",
"react-toastify": "^10.0.6", "react-toastify": "^10.0.6",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "5.2.2", "typescript": "5.2.2",
@@ -7042,6 +7043,11 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qr.js": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7102,6 +7108,18 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"node_modules/react-qr-code": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
"integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==",
"dependencies": {
"prop-types": "^15.8.1",
"qr.js": "0.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-toastify": { "node_modules/react-toastify": {
"version": "10.0.6", "version": "10.0.6",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz",

View File

@@ -36,6 +36,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
"react-qr-code": "^2.0.18",
"react-toastify": "^10.0.6", "react-toastify": "^10.0.6",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "5.2.2", "typescript": "5.2.2",
@@ -58,5 +59,5 @@
"engines": { "engines": {
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"version": "2.1.2" "version": "2.2.0"
} }