Browse Source

feat: add question components, media upload hook, and localization support

master
parent
commit
8ea69d39f2
  1. 126
      src/components/Componentes/question-file.tsx
  2. 213
      src/components/Componentes/question-photo.test.tsx
  3. 186
      src/components/Componentes/question-photo.tsx
  4. 2
      src/components/Componentes/question-section-flow.tsx
  5. 1
      src/hooks/marriage/use-upload-tmp-media.ts
  6. 71
      src/lib/http.ts
  7. 5
      src/translations/locales/ar.json
  8. 5
      src/translations/locales/az.json
  9. 5
      src/translations/locales/bn.json
  10. 5
      src/translations/locales/da.json
  11. 5
      src/translations/locales/de.json
  12. 5
      src/translations/locales/en.json
  13. 5
      src/translations/locales/es.json
  14. 5
      src/translations/locales/fa.json
  15. 5
      src/translations/locales/fr.json
  16. 5
      src/translations/locales/gu.json
  17. 5
      src/translations/locales/ha.json
  18. 5
      src/translations/locales/he.json
  19. 5
      src/translations/locales/hi.json
  20. 5
      src/translations/locales/id.json
  21. 5
      src/translations/locales/ks.json
  22. 5
      src/translations/locales/pt.json
  23. 5
      src/translations/locales/ru.json
  24. 5
      src/translations/locales/sw.json
  25. 5
      src/translations/locales/tg.json
  26. 5
      src/translations/locales/tr.json
  27. 5
      src/translations/locales/ul.json
  28. 5
      src/translations/locales/ur.json
  29. 5
      src/translations/locales/uz.json
  30. 5
      src/translations/locales/zh.json

126
src/components/Componentes/question-file.tsx

@ -7,9 +7,10 @@ import {
uploadTmpMedia, uploadTmpMedia,
useUploadTmpMediaMutation, useUploadTmpMediaMutation,
} from "@/hooks/marriage/use-upload-tmp-media"; } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http";
import { getApiRequestUrl, resolveMediaUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingSkeleton } from "./loading-skeleton";
@ -191,12 +192,7 @@ export function QuestionFile({
.filter((item): item is string | Record<string, unknown> => Boolean(item)) .filter((item): item is string | Record<string, unknown> => Boolean(item))
.map((item) => { .map((item) => {
if (typeof item === "string") { if (typeof item === "string") {
const resolvedUrl =
item.startsWith("http") ||
item.startsWith("blob:") ||
item.startsWith("data:")
? item
: getApiRequestUrl(item);
const resolvedUrl = resolveMediaUrl(item) ?? item;
return { return {
url: item, url: item,
previewUrl: resolvedUrl, previewUrl: resolvedUrl,
@ -206,22 +202,12 @@ export function QuestionFile({
const url = (item.url || item.path || "") as string; const url = (item.url || item.path || "") as string;
const name = const name =
(item.name || url.split("/").pop() || t.defaultDoc) as string; (item.name || url.split("/").pop() || t.defaultDoc) as string;
const resolvedUrl =
url.startsWith("http") ||
url.startsWith("blob:") ||
url.startsWith("data:")
? url
: getApiRequestUrl(url);
const resolvedUrl = resolveMediaUrl(url) ?? url;
return { url, name, previewUrl: resolvedUrl }; return { url, name, previewUrl: resolvedUrl };
}); });
} }
if (typeof stored === "string" && stored.trim().length > 0) { if (typeof stored === "string" && stored.trim().length > 0) {
const resolvedUrl =
stored.startsWith("http") ||
stored.startsWith("blob:") ||
stored.startsWith("data:")
? stored
: getApiRequestUrl(stored);
const resolvedUrl = resolveMediaUrl(stored) ?? stored;
return [ return [
{ {
url: stored, url: stored,
@ -242,6 +228,9 @@ export function QuestionFile({
const [uploadProgress, setUploadProgress] = useState<number | null>( const [uploadProgress, setUploadProgress] = useState<number | null>(
debugProgress ?? null, debugProgress ?? null,
); );
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [imgErrorSet, setImgErrorSet] = useState<Set<number>>(() => new Set());
const activeBlobUrlsRef = useRef<Map<string, string>>(new Map());
const isInitiatorRef = useRef(false); const isInitiatorRef = useRef(false);
const pendingDeferredRef = useRef<{ const pendingDeferredRef = useRef<{
resolve: (val?: any) => void; resolve: (val?: any) => void;
@ -249,6 +238,16 @@ export function QuestionFile({
} | null>(null); } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Revoke blob URLs on unmount
useEffect(() => {
return () => {
for (const blobUrl of activeBlobUrlsRef.current.values()) {
URL.revokeObjectURL(blobUrl);
}
activeBlobUrlsRef.current.clear();
};
}, []);
// Sync state when answers load asynchronously from server/cache // Sync state when answers load asynchronously from server/cache
useEffect(() => { useEffect(() => {
if (storedValue !== undefined && storedValue !== null) { if (storedValue !== undefined && storedValue !== null) {
@ -263,9 +262,7 @@ export function QuestionFile({
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) { if (response.path) {
const resolved = response.path.startsWith("http")
? response.path
: getApiRequestUrl(response.path);
const resolved = resolveMediaUrl(response.path) ?? response.path;
const newDoc: UploadedDoc = { const newDoc: UploadedDoc = {
url: response.path, url: response.path,
name: response.name ?? response.path.split("/").pop() ?? t.defaultDoc, name: response.name ?? response.path.split("/").pop() ?? t.defaultDoc,
@ -287,6 +284,7 @@ export function QuestionFile({
console.error("File upload error:", error); console.error("File upload error:", error);
setIsUploading(false); setIsUploading(false);
setUploadProgress(null); setUploadProgress(null);
setToastMessage(t.uploadFailed);
}, },
}); });
@ -330,12 +328,7 @@ export function QuestionFile({
(f?.data?.path as string | undefined) || (f?.data?.path as string | undefined) ||
(f?.data?.url as string | undefined); (f?.data?.url as string | undefined);
if (remoteUrl) { if (remoteUrl) {
const resolved =
remoteUrl.startsWith("http") ||
remoteUrl.startsWith("blob:") ||
remoteUrl.startsWith("data:")
? remoteUrl
: getApiRequestUrl(remoteUrl);
const resolved = resolveMediaUrl(remoteUrl) ?? remoteUrl;
newDocs.push({ newDocs.push({
url: remoteUrl, url: remoteUrl,
name: f?.name || remoteUrl.split("/").pop() || t.defaultDoc, name: f?.name || remoteUrl.split("/").pop() || t.defaultDoc,
@ -439,32 +432,59 @@ export function QuestionFile({
setIsUploading(true); setIsUploading(true);
setUploadProgress(0); setUploadProgress(0);
setToastMessage(null);
// Create instant blob previews for image files
const blobPreviews: { file: File; blobUrl: string | null }[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isImg = file.type.startsWith("image/");
if (isImg) {
const blobUrl = URL.createObjectURL(file);
blobPreviews.push({ file, blobUrl });
} else {
blobPreviews.push({ file, blobUrl: null });
}
}
const uploadTask = (async () => { const uploadTask = (async () => {
const newDocs: UploadedDoc[] = []; const newDocs: UploadedDoc[] = [];
let hasError = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
for (let i = 0; i < blobPreviews.length; i++) {
const { file, blobUrl } = blobPreviews[i];
try { try {
const res = await uploadTmpMedia(file, (p) => setUploadProgress(p)); const res = await uploadTmpMedia(file, (p) => setUploadProgress(p));
if (res.path) { if (res.path) {
const resolved = res.path.startsWith("http")
? res.path
: getApiRequestUrl(res.path);
const resolved = resolveMediaUrl(res.path) ?? res.path;
// Use blob preview URL if available (instant), otherwise use resolved backend URL
const previewUrl = blobUrl ?? resolved;
if (blobUrl) {
activeBlobUrlsRef.current.set(res.path, blobUrl);
}
newDocs.push({ newDocs.push({
url: res.path, url: res.path,
name: res.name || file.name, name: res.name || file.name,
previewUrl: resolved,
previewUrl,
}); });
} }
} catch (err) { } catch (err) {
console.error("Failed to upload tmp file:", file.name, err); console.error("Failed to upload tmp file:", file.name, err);
// Revoke blob URL on failure
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
hasError = true;
} }
} }
setIsUploading(false); setIsUploading(false);
setUploadProgress(null); setUploadProgress(null);
if (hasError && newDocs.length === 0) {
setToastMessage(t.uploadFailed);
}
if (newDocs.length > 0) { if (newDocs.length > 0) {
setUploadedDocs((prev) => { setUploadedDocs((prev) => {
const nextDocs = [...prev, ...newDocs]; const nextDocs = [...prev, ...newDocs];
@ -488,7 +508,25 @@ export function QuestionFile({
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setUploadedDocs((prev) => { setUploadedDocs((prev) => {
const removedDoc = prev[indexToRemove];
// Revoke blob URL if exists
if (removedDoc) {
const blobUrl = activeBlobUrlsRef.current.get(removedDoc.url);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
activeBlobUrlsRef.current.delete(removedDoc.url);
}
}
const nextDocs = prev.filter((_, idx) => idx !== indexToRemove); const nextDocs = prev.filter((_, idx) => idx !== indexToRemove);
// Clear image error tracking for removed index
setImgErrorSet((prevSet) => {
const newSet = new Set<number>();
for (const errIdx of prevSet) {
if (errIdx < indexToRemove) newSet.add(errIdx);
else if (errIdx > indexToRemove) newSet.add(errIdx - 1);
}
return newSet;
});
if (nextDocs.length === 0) { if (nextDocs.length === 0) {
setAnswerValue(question, null); setAnswerValue(question, null);
} else if (nextDocs.length === 1) { } else if (nextDocs.length === 1) {
@ -522,6 +560,13 @@ export function QuestionFile({
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
{toastMessage && (
<ErrorToast
message={toastMessage}
onClose={() => setToastMessage(null)}
/>
)}
<QuestionTitle question={question} /> <QuestionTitle question={question} />
{/* Hidden browser input for fallback */} {/* Hidden browser input for fallback */}
@ -561,17 +606,22 @@ export function QuestionFile({
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
{uploadedDocs.map((doc, index) => { {uploadedDocs.map((doc, index) => {
const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url); const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url);
const hasThumbError = imgErrorSet.has(index);
const ext = doc.name?.split(".").pop()?.toUpperCase() || "FILE";
return ( return (
<div <div
key={`${doc.url}-${index}`} key={`${doc.url}-${index}`}
className="relative flex w-full items-center justify-between gap-3 rounded-[16px] border border-[#E5E7EB] bg-white p-2.5 shadow-xs transition-shadow hover:shadow-sm" className="relative flex w-full items-center justify-between gap-3 rounded-[16px] border border-[#E5E7EB] bg-white p-2.5 shadow-xs transition-shadow hover:shadow-sm"
> >
{/* Left: Thumbnail or PDF Icon */}
{/* Left: Thumbnail or File Icon */}
<div className="flex items-center gap-3 overflow-hidden"> <div className="flex items-center gap-3 overflow-hidden">
{isImg && doc.previewUrl ? (
{isImg && doc.previewUrl && !hasThumbError ? (
<img <img
src={doc.previewUrl} src={doc.previewUrl}
alt={doc.name}
alt=""
onError={() => {
setImgErrorSet((prev) => new Set(prev).add(index));
}}
className="h-12 w-12 shrink-0 rounded-[10px] object-cover border border-[#E5E7EB]" className="h-12 w-12 shrink-0 rounded-[10px] object-cover border border-[#E5E7EB]"
/> />
) : ( ) : (
@ -590,7 +640,7 @@ export function QuestionFile({
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" /> <path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg> </svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[7px] font-bold text-white leading-none"> <span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[7px] font-bold text-white leading-none">
PDF
{ext.length <= 4 ? ext : "FILE"}
</span> </span>
</div> </div>
)} )}

213
src/components/Componentes/question-photo.test.tsx

@ -0,0 +1,213 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionPhoto } from "./question-photo";
let answerMap: Record<string, unknown> = {};
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val;
});
const mockRegisterPendingUpload = vi.fn();
const mockUploadTmpMedia = vi.fn(async (file: File) => ({
path: `/media/tmp/${file.name}`,
name: file.name,
}));
vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({
useUploadTmpMediaMutation: ({
onSuccess,
onError,
}: {
onSuccess?: (data: any) => void;
onError?: (err: any) => void;
}) => ({
mutate: (file: File) => {
mockUploadTmpMedia(file)
.then((res) => onSuccess?.(res))
.catch((err) => onError?.(err));
},
mutateAsync: async (file: File) => {
try {
const res = await mockUploadTmpMedia(file);
onSuccess?.(res);
return res;
} catch (err) {
onError?.(err);
throw err;
}
},
isPending: false,
isError: false,
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null,
setAnswerValue: mockSetAnswerValue,
registerPendingUpload: mockRegisterPendingUpload,
isLoading: false,
}),
}));
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
dictionary: {
"Photo upload failed. Please try again.": "Photo upload failed. Please try again.",
"Failed to load image. Please try uploading again.": "Failed to load image. Please try uploading again.",
},
}),
}));
const mockPhotoQuestion: QuestionField = {
id: "documents_verification.face_photo",
title: "Face Photo",
type: "photo",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "Recent clear face photo",
tooltip: "",
extras: {
options: [".jpg", ".jpeg", ".png"],
},
options: [],
};
describe("QuestionPhoto Component", () => {
beforeEach(() => {
answerMap = {};
mockSetAnswerValue.mockClear();
mockUploadTmpMedia.mockClear();
mockRegisterPendingUpload.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("renders default avatar placeholder when no photo uploaded", () => {
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
expect(screen.getByText(/Face/)).toBeInTheDocument();
expect(screen.getByText(/Photo/)).toBeInTheDocument();
expect(screen.getByText("Recent clear face photo")).toBeInTheDocument();
// Default placeholder SVG is rendered
expect(container.querySelector("img[src*='Frame 2095586679.svg']")).toBeInTheDocument();
});
it("renders uploaded image and camera badge when answer is provided", () => {
answerMap[mockPhotoQuestion.id] = "https://example.com/avatar.jpg";
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const img = container.querySelector("img[src='https://example.com/avatar.jpg']");
expect(img).toBeInTheDocument();
expect(img).toHaveClass("rounded-full");
// Camera badge rendered
expect(container.querySelector(".bg-\\[\\#F0445B\\]")).toBeInTheDocument();
});
it("triggers upload and renders circular spinner without black background overlay during file selection", async () => {
let resolveUpload!: (val: any) => void;
mockUploadTmpMedia.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const fileInput = container.querySelector("input[type='file']") as HTMLInputElement;
const testFile = new File(["dummy content"], "my-photo.jpg", { type: "image/jpeg" });
const originalCreateObjectURL = URL.createObjectURL;
URL.createObjectURL = vi.fn(() => "blob:http://localhost/test-blob");
try {
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => {
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass("border-[#F0445B]/25");
});
expect(container.querySelector(".bg-black\\/40")).toBeNull();
expect(container.querySelector(".bg-black")).toBeNull();
resolveUpload({ path: "/media/uploaded.jpg" });
await waitFor(() => {
expect(container.querySelector(".animate-spin")).toBeNull();
});
} finally {
URL.createObjectURL = originalCreateObjectURL;
}
});
it("shows error toast, reverts to default avatar placeholder, and clears answer when upload fails", async () => {
mockUploadTmpMedia.mockImplementationOnce(
() => Promise.reject(new Error("Network Error")),
);
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const fileInput = container.querySelector("input[type='file']") as HTMLInputElement;
const testFile = new File(["dummy content"], "bad-photo.jpg", { type: "image/jpeg" });
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = vi.fn(() => "blob:http://localhost/bad-blob");
URL.revokeObjectURL = vi.fn();
try {
fireEvent.change(fileInput, { target: { files: [testFile] } });
// Error toast should appear
await waitFor(() => {
expect(screen.getByText("Photo upload failed. Please try again.")).toBeInTheDocument();
});
// Answer value is cleared
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockPhotoQuestion, null);
// Default avatar SVG placeholder is restored
expect(container.querySelector("img[src*='Frame 2095586679.svg']")).toBeInTheDocument();
// Camera badge is not visible
expect(container.querySelector(".bg-\\[\\#F0445B\\]")).toBeNull();
// File input is reset allowing re-upload
expect(fileInput.value).toBe("");
} finally {
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});
it("shows error toast and reverts to default avatar when rendered image fails to load", async () => {
answerMap[mockPhotoQuestion.id] = "https://example.com/broken-avatar.jpg";
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const img = container.querySelector("img[src='https://example.com/broken-avatar.jpg']") as HTMLImageElement;
expect(img).toBeInTheDocument();
// Trigger image error (e.g. 404 / ERR_CONNECTION_REFUSED)
fireEvent.error(img);
await waitFor(() => {
expect(screen.getByText("Failed to load image. Please try uploading again.")).toBeInTheDocument();
});
// Answer value cleared
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockPhotoQuestion, null);
// Default avatar placeholder rendered instead of broken image
expect(container.querySelector("img[src*='Frame 2095586679.svg']")).toBeInTheDocument();
expect(container.querySelector(".bg-\\[\\#F0445B\\]")).toBeNull();
});
});

186
src/components/Componentes/question-photo.tsx

@ -4,11 +4,12 @@ import Image from "next/image";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http";
import { resolveMediaUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton";
type QuestionPhotoProps = { type QuestionPhotoProps = {
question: QuestionField; question: QuestionField;
@ -22,15 +23,21 @@ export function QuestionPhoto({
disabled, disabled,
}: QuestionPhotoProps) { }: QuestionPhotoProps) {
const inputId = useId(); const inputId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const activeBlobUrlRef = useRef<string | null>(null);
const { dictionary: t } = useI18n();
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [hasImageError, setHasImageError] = useState(false);
const acceptedFiles = const acceptedFiles =
question.extras?.options && question.extras.options.length > 0 question.extras?.options && question.extras.options.length > 0
? question.extras.options.join(",") ? question.extras.options.join(",")
: "image/*"; : "image/*";
const descriptionContent = description ?? question.description;
const { getAnswerValue, setAnswerValue, registerPendingUpload } = const { getAnswerValue, setAnswerValue, registerPendingUpload } =
useQuestionAnswers(); useQuestionAnswers();
const storedValue = getAnswerValue(question); const storedValue = getAnswerValue(question);
@ -41,17 +48,72 @@ export function QuestionPhoto({
reject: (err?: any) => void; reject: (err?: any) => void;
} | null>(null); } | null>(null);
// Revoke blob URL on unmount
useEffect(() => {
return () => {
if (activeBlobUrlRef.current) {
URL.revokeObjectURL(activeBlobUrlRef.current);
activeBlobUrlRef.current = null;
}
};
}, []);
const handleUploadFailure = useCallback(
(errorMessage?: string) => {
setIsUploading(false);
if (activeBlobUrlRef.current) {
URL.revokeObjectURL(activeBlobUrlRef.current);
activeBlobUrlRef.current = null;
}
setLocalPreviewUrl(null);
setAnswerValue(question, null);
setHasImageError(true);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setToastMessage(
errorMessage ??
t["Photo upload failed. Please try again."] ??
"Photo upload failed. Please try again.",
);
},
[question, setAnswerValue, t],
);
const handleImageError = useCallback(() => {
console.error("Photo preview failed to load");
if (activeBlobUrlRef.current) {
URL.revokeObjectURL(activeBlobUrlRef.current);
activeBlobUrlRef.current = null;
}
setLocalPreviewUrl(null);
setAnswerValue(question, null);
setHasImageError(true);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setToastMessage(
t["Failed to load image. Please try uploading again."] ??
"Failed to load image. Please try uploading again.",
);
}, [question, setAnswerValue, t]);
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) {
if (response?.path) {
setAnswerValue(question, response.path); setAnswerValue(question, response.path);
setHasImageError(false);
if (!activeBlobUrlRef.current) {
setLocalPreviewUrl(response.path); setLocalPreviewUrl(response.path);
} }
} else {
handleUploadFailure();
}
setIsUploading(false); setIsUploading(false);
}, },
onError: (error) => { onError: (error) => {
console.error("Photo upload error:", error); console.error("Photo upload error:", error);
setIsUploading(false);
handleUploadFailure();
}, },
}); });
@ -72,6 +134,8 @@ export function QuestionPhoto({
case "picked": case "picked":
case "progress": case "progress":
setIsUploading(true); setIsUploading(true);
setHasImageError(false);
setToastMessage(null);
if (event.data?.files?.[0]?.base64) { if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64; const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
@ -88,7 +152,8 @@ export function QuestionPhoto({
(file?.data?.url as string | undefined); (file?.data?.url as string | undefined);
if (remoteUrl) { if (remoteUrl) {
setAnswerValue(question, remoteUrl); setAnswerValue(question, remoteUrl);
setLocalPreviewUrl(remoteUrl);
setLocalPreviewUrl((prev) => prev || remoteUrl);
setHasImageError(false);
pendingDeferredRef.current?.resolve(remoteUrl); pendingDeferredRef.current?.resolve(remoteUrl);
pendingDeferredRef.current = null; pendingDeferredRef.current = null;
} else if (file?.base64) { } else if (file?.base64) {
@ -105,14 +170,18 @@ export function QuestionPhoto({
.then((res) => { .then((res) => {
if (res?.path) { if (res?.path) {
setAnswerValue(question, res.path); setAnswerValue(question, res.path);
setLocalPreviewUrl(res.path);
setLocalPreviewUrl((prev) => prev || res.path);
setHasImageError(false);
pendingDeferredRef.current?.resolve(res.path); pendingDeferredRef.current?.resolve(res.path);
} else { } else {
handleUploadFailure();
pendingDeferredRef.current?.resolve(); pendingDeferredRef.current?.resolve();
} }
pendingDeferredRef.current = null; pendingDeferredRef.current = null;
}) })
.catch((err) => { .catch((err) => {
console.error("Flutter photo upload error:", err);
handleUploadFailure();
pendingDeferredRef.current?.reject(err); pendingDeferredRef.current?.reject(err);
pendingDeferredRef.current = null; pendingDeferredRef.current = null;
}); });
@ -124,10 +193,19 @@ export function QuestionPhoto({
break; break;
} }
case "cancelled": case "cancelled":
setIsUploading(false);
isInitiatorRef.current = false;
if (!storedValue) {
setLocalPreviewUrl(null);
}
pendingDeferredRef.current?.reject(new Error("Upload cancelled"));
pendingDeferredRef.current = null;
break;
case "failed": case "failed":
setIsUploading(false); setIsUploading(false);
isInitiatorRef.current = false; isInitiatorRef.current = false;
pendingDeferredRef.current?.reject(new Error("Upload failed or cancelled"));
handleUploadFailure();
pendingDeferredRef.current?.reject(new Error("Upload failed"));
pendingDeferredRef.current = null; pendingDeferredRef.current = null;
break; break;
} }
@ -136,7 +214,14 @@ export function QuestionPhoto({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [question, setAnswerValue, registerPendingUpload, uploadTmpMediaMutation]);
}, [
question,
setAnswerValue,
registerPendingUpload,
uploadTmpMediaMutation,
handleUploadFailure,
storedValue,
]);
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) => const extensions = (question.extras?.options ?? []).map((o) =>
@ -145,6 +230,8 @@ export function QuestionPhoto({
isInitiatorRef.current = true; isInitiatorRef.current = true;
setIsUploading(true); setIsUploading(true);
setHasImageError(false);
setToastMessage(null);
let resolveFn!: (val?: any) => void; let resolveFn!: (val?: any) => void;
let rejectFn!: (err?: any) => void; let rejectFn!: (err?: any) => void;
@ -184,8 +271,28 @@ export function QuestionPhoto({
const file = files?.[0]; const file = files?.[0];
if (!file) return; if (!file) return;
// Create synchronous object URL for instant UI preview only
// Validate size (10 MB limit)
if (file.size > 10_485_760) {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setToastMessage(
t["File size exceeds the 10 MB limit."] ??
"File size exceeds the 10 MB limit.",
);
return;
}
// Reset previous errors
setHasImageError(false);
setToastMessage(null);
// Create synchronous object URL for instant UI preview
if (activeBlobUrlRef.current) {
URL.revokeObjectURL(activeBlobUrlRef.current);
}
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
activeBlobUrlRef.current = objectUrl;
setLocalPreviewUrl(objectUrl); setLocalPreviewUrl(objectUrl);
// Trigger background upload and register promise with central storage // Trigger background upload and register promise with central storage
@ -195,13 +302,18 @@ export function QuestionPhoto({
.then((res) => { .then((res) => {
if (res?.path) { if (res?.path) {
setAnswerValue(question, res.path); setAnswerValue(question, res.path);
setHasImageError(false);
if (!activeBlobUrlRef.current) {
setLocalPreviewUrl(res.path); setLocalPreviewUrl(res.path);
} }
} else {
handleUploadFailure();
}
setIsUploading(false); setIsUploading(false);
}) })
.catch((err) => { .catch((err) => {
console.error("Photo upload error:", err); console.error("Photo upload error:", err);
setIsUploading(false);
handleUploadFailure();
}); });
registerPendingUpload?.(question.id, uploadPromise); registerPendingUpload?.(question.id, uploadPromise);
@ -215,27 +327,19 @@ export function QuestionPhoto({
}; };
// Determine photo URL to display // Determine photo URL to display
let displayUrl: string | null = localPreviewUrl;
let candidateUrl: string | null = localPreviewUrl;
if ( if (
!displayUrl &&
!candidateUrl &&
!hasImageError &&
typeof storedValue === "string" && typeof storedValue === "string" &&
storedValue.trim().length > 0 storedValue.trim().length > 0
) { ) {
const val = storedValue.trim();
if (
val.startsWith("http://") ||
val.startsWith("https://") ||
val.startsWith("blob:") ||
val.startsWith("data:")
) {
displayUrl = val;
} else {
displayUrl = getApiRequestUrl(val);
}
candidateUrl = storedValue.trim();
} }
const hasAnswer = Boolean(displayUrl);
const displayUrl = hasImageError ? null : resolveMediaUrl(candidateUrl);
const hasAnswer = Boolean(displayUrl && !hasImageError);
return ( return (
<div <div
@ -245,12 +349,20 @@ export function QuestionPhoto({
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
{toastMessage && (
<ErrorToast
message={toastMessage}
onClose={() => setToastMessage(null)}
/>
)}
<QuestionTitle <QuestionTitle
question={{ ...question, description: "" }}
question={question}
className="justify-center text-center" className="justify-center text-center"
/> />
<input <input
ref={fileInputRef}
id={inputId} id={inputId}
type="file" type="file"
accept={acceptedFiles} accept={acceptedFiles}
@ -264,11 +376,12 @@ export function QuestionPhoto({
className="flex w-full cursor-pointer flex-col items-center" className="flex w-full cursor-pointer flex-col items-center"
> >
<span className="flex w-full flex-col items-center"> <span className="flex w-full flex-col items-center">
<div className="relative flex h-[92px] w-[86px] items-center justify-center">
{displayUrl ? (
<div className="relative flex h-[92px] w-[86px] items-start justify-center">
{hasAnswer && displayUrl ? (
<img <img
src={displayUrl} src={displayUrl}
alt={typeof question.title === "string" ? question.title : ""}
alt=""
onError={handleImageError}
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]" className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
/> />
) : ( ) : (
@ -283,8 +396,8 @@ export function QuestionPhoto({
)} )}
{/* Red camera badge when image is uploaded */} {/* Red camera badge when image is uploaded */}
{displayUrl && (
<span className="absolute bottom-[2px] right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
{hasAnswer && !isPending && (
<span className="absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
<svg <svg
width="14" width="14"
height="14" height="14"
@ -302,19 +415,14 @@ export function QuestionPhoto({
</span> </span>
)} )}
{/* Loading spinner during upload matching Flutter ProfileAvatarWidget */}
{/* Circular loading spinner during upload without black overlay */}
{isPending && ( {isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px] z-10 transition-all duration-200">
<div className="h-7 w-7 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
<div className="absolute top-0 left-0 flex h-[86px] w-[86px] items-center justify-center rounded-full pointer-events-none z-10">
<div className="size-7 animate-spin rounded-full border-[2.5px] border-[#F0445B]/25 border-t-[#F0445B]" />
</div> </div>
)} )}
</div> </div>
{descriptionContent ? (
<span className="mt-4 block max-w-[350px] group-10 leading-[1.35] font-semibold text-[#D44747]">
{descriptionContent}
</span>
) : null}
</span> </span>
</label> </label>
</div> </div>

2
src/components/Componentes/question-section-flow.tsx

@ -298,7 +298,7 @@ function SectionFlowContent({
<FixToTheEnd> <FixToTheEnd>
<Button <Button
disabled={!isCompleted || isSubmitting || isUploadingMedia} disabled={!isCompleted || isSubmitting || isUploadingMedia}
isLoading={isSubmitting || isUploadingMedia}
isLoading={isSubmitting}
onClick={handleSubmit} onClick={handleSubmit}
> >
{continueLabel || t["Submit"]} {continueLabel || t["Submit"]}

1
src/hooks/marriage/use-upload-tmp-media.ts

@ -21,6 +21,7 @@ export async function uploadTmpMedia(
{ {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "multipart/form-data",
"X-CSRFToken": CSRF_TOKEN, "X-CSRFToken": CSRF_TOKEN,
}, },
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {

71
src/lib/http.ts

@ -54,6 +54,77 @@ export function getApiRequestUrl(path: string) {
return `${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`; return `${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`;
} }
export function resolveMediaUrl(url: string | null | undefined): string | null {
if (!url || typeof url !== "string") {
return null;
}
const trimmed = url.trim();
if (!trimmed) {
return null;
}
// Blob and data URLs are generated in the current browser session and work immediately
if (trimmed.startsWith("blob:") || trimmed.startsWith("data:")) {
return trimmed;
}
if (shouldUseProxy()) {
if (isAbsoluteUrl(trimmed)) {
try {
const parsed = new URL(trimmed);
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
let isBackendHost = false;
if (
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "localhost" ||
parsed.port === "8000" ||
parsed.port === "8001"
) {
isBackendHost = true;
} else if (apiBase) {
try {
const apiParsed = new URL(apiBase);
if (parsed.host === apiParsed.host) {
isBackendHost = true;
}
} catch {
// Ignore URL parsing error
}
}
if (
isBackendHost ||
parsed.pathname.startsWith("/static/") ||
parsed.pathname.startsWith("/media/")
) {
const proxyPath = `${parsed.pathname}${parsed.search}`;
const searchParams = new URLSearchParams({
[PROXY_PATH_PARAM]: proxyPath,
});
return `/api/proxy?${searchParams.toString()}`;
}
} catch {
// Fall through
}
} else {
const normalizedPath = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
const searchParams = new URLSearchParams({
[PROXY_PATH_PARAM]: normalizedPath,
});
return `/api/proxy?${searchParams.toString()}`;
}
}
if (isAbsoluteUrl(trimmed)) {
return trimmed;
}
const base = process.env.NEXT_PUBLIC_API_BASE_URL || "";
const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return `${base}${normalized}`;
}
export const http = axios.create({ export const http = axios.create({
baseURL: shouldUseProxy() baseURL: shouldUseProxy()
? "/api/proxy" ? "/api/proxy"

5
src/translations/locales/ar.json

@ -2118,5 +2118,8 @@
"8 min": "8 دقائق", "8 min": "8 دقائق",
"10 min": "10 دقائق", "10 min": "10 دقائق",
"15 min": "15 دقيقة", "15 min": "15 دقيقة",
"16 min": "16 دقيقة"
"16 min": "16 دقيقة",
"Photo upload failed. Please try again.": "فشل تحميل الصورة. يرجى المحاولة مرة أخرى.",
"Failed to load image. Please try uploading again.": "فشل تحميل الصورة. يرجى إعادة المحاولة.",
"File size exceeds the 10 MB limit.": "حجم الملف يتجاوز الحد المسموح به وهو 10 ميغابايت."
} }

5
src/translations/locales/az.json

@ -2116,5 +2116,8 @@
"8 min": "8 dəq", "8 min": "8 dəq",
"10 min": "10 dəq", "10 min": "10 dəq",
"15 min": "15 dəq", "15 min": "15 dəq",
"16 min": "16 dəq"
"16 min": "16 dəq",
"Photo upload failed. Please try again.": "Şəkil yüklənmədi. Zəhmət olmasa yenidən cəhd edin.",
"Failed to load image. Please try uploading again.": "Şəkil yüklənmədi. Zəhmət olmasa yenidən yükləyin.",
"File size exceeds the 10 MB limit.": "Fayl ölçüsü 10 MB limitini aşır."
} }

5
src/translations/locales/bn.json

@ -2116,5 +2116,8 @@
"8 min": "৮ মিনিট", "8 min": "৮ মিনিট",
"10 min": "১০ মিনিট", "10 min": "১০ মিনিট",
"15 min": "১৫ মিনিট", "15 min": "১৫ মিনিট",
"16 min": "১৬ মিনিট"
"16 min": "১৬ মিনিট",
"Photo upload failed. Please try again.": "ছবি আপলোড ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"Failed to load image. Please try uploading again.": "ছবি লোড করতে ব্যর্থ হয়েছে। অনুগ্রহ করে আবার আপলোড করুন।",
"File size exceeds the 10 MB limit.": "ফাইলের আকার 10 MB সীমা অতিক্রম করেছে।"
} }

5
src/translations/locales/da.json

@ -2116,5 +2116,8 @@
"8 min": "8 min", "8 min": "8 min",
"10 min": "10 min", "10 min": "10 min",
"15 min": "15 min", "15 min": "15 min",
"16 min": "16 min"
"16 min": "16 min",
"Photo upload failed. Please try again.": "Billedoverførsel mislykkedes. Prøv venligst igen.",
"Failed to load image. Please try uploading again.": "Kunne ikke indlæse billede. Prøv venligst at uploade igen.",
"File size exceeds the 10 MB limit.": "Filstørrelsen overstiger grænsen på 10 MB."
} }

5
src/translations/locales/de.json

@ -2116,5 +2116,8 @@
"8 min": "8 Min.", "8 min": "8 Min.",
"10 min": "10 Min.", "10 min": "10 Min.",
"15 min": "15 Min.", "15 min": "15 Min.",
"16 min": "16 Min."
"16 min": "16 Min.",
"Photo upload failed. Please try again.": "Foto-Upload fehlgeschlagen. Bitte versuchen Sie es erneut.",
"Failed to load image. Please try uploading again.": "Fehler beim Laden des Bildes. Bitte erneut hochladen.",
"File size exceeds the 10 MB limit.": "Die Dateigröße überschreitet das Limit von 10 MB."
} }

5
src/translations/locales/en.json

@ -2107,5 +2107,8 @@
"8 min": "8 min", "8 min": "8 min",
"10 min": "10 min", "10 min": "10 min",
"15 min": "15 min", "15 min": "15 min",
"16 min": "16 min"
"16 min": "16 min",
"Photo upload failed. Please try again.": "Photo upload failed. Please try again.",
"Failed to load image. Please try uploading again.": "Failed to load image. Please try uploading again.",
"File size exceeds the 10 MB limit.": "File size exceeds the 10 MB limit."
} }

5
src/translations/locales/es.json

@ -2116,5 +2116,8 @@
"8 min": "8 min", "8 min": "8 min",
"10 min": "10 min", "10 min": "10 min",
"15 min": "15 min", "15 min": "15 min",
"16 min": "16 min"
"16 min": "16 min",
"Photo upload failed. Please try again.": "Error al subir la foto. Inténtalo de nuevo.",
"Failed to load image. Please try uploading again.": "Error al cargar la imagen. Intenta subirla de nuevo.",
"File size exceeds the 10 MB limit.": "El tamaño del archivo supera el límite de 10 MB."
} }

5
src/translations/locales/fa.json

@ -2128,5 +2128,8 @@
"8 min": "۸ دقیقه", "8 min": "۸ دقیقه",
"10 min": "۱۰ دقیقه", "10 min": "۱۰ دقیقه",
"15 min": "۱۵ دقیقه", "15 min": "۱۵ دقیقه",
"16 min": "۱۶ دقیقه"
"16 min": "۱۶ دقیقه",
"Photo upload failed. Please try again.": "خطا در آپلود عکس. لطفاً دوباره امتحان کنید.",
"Failed to load image. Please try uploading again.": "خطا در بارگذاری تصویر. لطفاً مجدداً آپلود کنید.",
"File size exceeds the 10 MB limit.": "حجم فایل بیشتر از حد مجاز (۱۰ مگابایت) است."
} }

5
src/translations/locales/fr.json

@ -2116,5 +2116,8 @@
"8 min": "8 min", "8 min": "8 min",
"10 min": "10 min", "10 min": "10 min",
"15 min": "15 min", "15 min": "15 min",
"16 min": "16 min"
"16 min": "16 min",
"Photo upload failed. Please try again.": "Échec du téléversement de la photo. Veuillez réessayer.",
"Failed to load image. Please try uploading again.": "Échec du chargement de l'image. Veuillez réessayer le téléversement.",
"File size exceeds the 10 MB limit.": "La taille du fichier dépasse la limite de 10 Mo."
} }

5
src/translations/locales/gu.json

@ -2118,5 +2118,8 @@
"8 min": "8 મિનિટ", "8 min": "8 મિનિટ",
"10 min": "10 મિનિટ", "10 min": "10 મિનિટ",
"15 min": "15 મિનિટ", "15 min": "15 મિનિટ",
"16 min": "16 મિનિટ"
"16 min": "16 મિનિટ",
"Photo upload failed. Please try again.": "ફોટો અપલોડ નિષ્ફળ ગયો. કૃપા કરીને ફરી પ્રયાસ કરો.",
"Failed to load image. Please try uploading again.": "છબી લોડ કરવામાં નિષ્ફળ. કૃપા કરીને ફરીથી અપલોડ કરવાનો પ્રયાસ કરો.",
"File size exceeds the 10 MB limit.": "ફાઇલનું કદ 10 MB ની મર્યાદા કરતાં વધી ગયું છે."
} }

5
src/translations/locales/ha.json

@ -2116,5 +2116,8 @@
"8 min": "Minti 8", "8 min": "Minti 8",
"10 min": "Minti 10", "10 min": "Minti 10",
"15 min": "Minti 15", "15 min": "Minti 15",
"16 min": "Minti 16"
"16 min": "Minti 16",
"Photo upload failed. Please try again.": "Kashin hoton ya gaza. Da fatan za a sake gwadawa.",
"Failed to load image. Please try uploading again.": "An kasa loda hoton. Da fatan za a sake lodawa.",
"File size exceeds the 10 MB limit.": "Girman fayil ya wuce iyakar 10 MB."
} }

5
src/translations/locales/he.json

@ -2381,5 +2381,8 @@
"8 min": "8 דק׳", "8 min": "8 דק׳",
"10 min": "10 דק׳", "10 min": "10 דק׳",
"15 min": "15 דק׳", "15 min": "15 דק׳",
"16 min": "16 דק׳"
"16 min": "16 דק׳",
"Photo upload failed. Please try again.": "העלאת התמונה נכשלה. אנא נסה שוב.",
"Failed to load image. Please try uploading again.": "טעינת התמונה נכשלה. אנא נסה להעלות שוב.",
"File size exceeds the 10 MB limit.": "גודל הקובץ חורג ממגבלת 10 MB."
} }

5
src/translations/locales/hi.json

@ -2116,5 +2116,8 @@
"8 min": "8 मिनट", "8 min": "8 मिनट",
"10 min": "10 मिनट", "10 min": "10 मिनट",
"15 min": "15 मिनट", "15 min": "15 मिनट",
"16 min": "16 मिनट"
"16 min": "16 मिनट",
"Photo upload failed. Please try again.": "फ़ोटो अपलोड विफल रहा। कृपया पुन: प्रयास करें।",
"Failed to load image. Please try uploading again.": "छवि लोड करने में विफल। कृपया फिर से अपलोड करने का प्रयास करें।",
"File size exceeds the 10 MB limit.": "फ़ाइल का आकार 10 MB की सीमा से अधिक है।"
} }

5
src/translations/locales/id.json

@ -2381,5 +2381,8 @@
"8 min": "8 mnt", "8 min": "8 mnt",
"10 min": "10 mnt", "10 min": "10 mnt",
"15 min": "15 mnt", "15 min": "15 mnt",
"16 min": "16 mnt"
"16 min": "16 mnt",
"Photo upload failed. Please try again.": "Gagal mengunggah foto. Silakan coba lagi.",
"Failed to load image. Please try uploading again.": "Gagal memuat gambar. Silakan coba unggah lagi.",
"File size exceeds the 10 MB limit.": "Ukuran file melebihi batas 10 MB."
} }

5
src/translations/locales/ks.json

@ -2381,5 +2381,8 @@
"8 min": "۸ منٹ", "8 min": "۸ منٹ",
"10 min": "۱۰ منٹ", "10 min": "۱۰ منٹ",
"15 min": "۱۵ منٹ", "15 min": "۱۵ منٹ",
"16 min": "۱۶ منٹ"
"16 min": "۱۶ منٹ",
"Photo upload failed. Please try again.": "فوٹو اپلوڈ گژھنہٕ منٛز خرٲبی۔ مہر کرِو دوبارٕ کوشش۔",
"Failed to load image. Please try uploading again.": "تصویر لوڈ گژھنہٕ منٛز خرٲبی۔ مہربٲنی کٔرِتھ کرِو دوبارٕ کوشش۔",
"File size exceeds the 10 MB limit.": "فائل سائِز چھُ 10 MB حد کھوتہٕ زیادہ۔"
} }

5
src/translations/locales/pt.json

@ -2381,5 +2381,8 @@
"8 min": "8 min", "8 min": "8 min",
"10 min": "10 min", "10 min": "10 min",
"15 min": "15 min", "15 min": "15 min",
"16 min": "16 min"
"16 min": "16 min",
"Photo upload failed. Please try again.": "Falha ao enviar foto. Por favor, tente novamente.",
"Failed to load image. Please try uploading again.": "Falha ao carregar a imagem. Tente carregar novamente.",
"File size exceeds the 10 MB limit.": "O tamanho do arquivo excede o limite de 10 MB."
} }

5
src/translations/locales/ru.json

@ -2112,5 +2112,8 @@
"8 min": "8 мин", "8 min": "8 мин",
"10 min": "10 мин", "10 min": "10 мин",
"15 min": "15 мин", "15 min": "15 мин",
"16 min": "16 мин"
"16 min": "16 мин",
"Photo upload failed. Please try again.": "Ошибка загрузки фото. Пожалуйста, попробуйте еще раз.",
"Failed to load image. Please try uploading again.": "Не удалось загрузить изображение. Попробуйте загрузить снова.",
"File size exceeds the 10 MB limit.": "Размер файла превышает лимит 10 МБ."
} }

5
src/translations/locales/sw.json

@ -2381,5 +2381,8 @@
"8 min": "dak 8", "8 min": "dak 8",
"10 min": "dak 10", "10 min": "dak 10",
"15 min": "dak 15", "15 min": "dak 15",
"16 min": "dak 16"
"16 min": "dak 16",
"Photo upload failed. Please try again.": "Upakiaji wa picha umeshindikana. Tafadhali jaribu tena.",
"Failed to load image. Please try uploading again.": "Imeshindwa kupakia picha. Tafadhali jaribu kupakia tena.",
"File size exceeds the 10 MB limit.": "Ukubwa wa faili unazidi kikomo cha MB 10."
} }

5
src/translations/locales/tg.json

@ -2381,5 +2381,8 @@
"8 min": "8 дақ", "8 min": "8 дақ",
"10 min": "10 дақ", "10 min": "10 дақ",
"15 min": "15 дақ", "15 min": "15 дақ",
"16 min": "16 дақ"
"16 min": "16 дақ",
"Photo upload failed. Please try again.": "Боркунии акс ноком шуд. Лутфан бори дигар кӯшиш кунед.",
"Failed to load image. Please try uploading again.": "Боргирии тасвир ноком шуд. Лутфан бори дигар бор кунед.",
"File size exceeds the 10 MB limit.": "Ҳаҷми файл аз ҳадди 10 МБ зиёд аст."
} }

5
src/translations/locales/tr.json

@ -2381,5 +2381,8 @@
"8 min": "8 dk", "8 min": "8 dk",
"10 min": "10 dk", "10 min": "10 dk",
"15 min": "15 dk", "15 min": "15 dk",
"16 min": "16 dk"
"16 min": "16 dk",
"Photo upload failed. Please try again.": "Fotoğraf yüklenemedi. Lütfen tekrar deneyin.",
"Failed to load image. Please try uploading again.": "Resim yüklenemedi. Lütfen tekrar yüklemeyi deneyin.",
"File size exceeds the 10 MB limit.": "Dosya boyutu 10 MB sınırını aşıyor."
} }

5
src/translations/locales/ul.json

@ -2381,5 +2381,8 @@
"8 min": "8 min", "8 min": "8 min",
"10 min": "10 min", "10 min": "10 min",
"15 min": "15 min", "15 min": "15 min",
"16 min": "16 min"
"16 min": "16 min",
"Photo upload failed. Please try again.": "Tasveer upload nakam ho gayi. Barah-e-karam dobara koshish karein.",
"Failed to load image. Please try uploading again.": "Tasveer load karne mein nakami. Barah-e-karam dobara upload karein.",
"File size exceeds the 10 MB limit.": "File ka size 10 MB ki had se zyada hai."
} }

5
src/translations/locales/ur.json

@ -2381,5 +2381,8 @@
"8 min": "8 منٹ", "8 min": "8 منٹ",
"10 min": "10 منٹ", "10 min": "10 منٹ",
"15 min": "15 منٹ", "15 min": "15 منٹ",
"16 min": "16 منٹ"
"16 min": "16 منٹ",
"Photo upload failed. Please try again.": "تصویر اپ لوڈ نہیں ہو سکی۔ براہ کرم دوبارہ کوشش کریں۔",
"Failed to load image. Please try uploading again.": "تصویر لوڈ کرنے میں ناکامی۔ براہ کرم دوبارہ اپ لوڈ کریں۔",
"File size exceeds the 10 MB limit.": "فائل کا سائز 10 MB کی حد سے تجاوز کر گیا ہے۔"
} }

5
src/translations/locales/uz.json

@ -2381,5 +2381,8 @@
"8 min": "8 daq", "8 min": "8 daq",
"10 min": "10 daq", "10 min": "10 daq",
"15 min": "15 daq", "15 min": "15 daq",
"16 min": "16 daq"
"16 min": "16 daq",
"Photo upload failed. Please try again.": "Surat yuklanmadi. Iltimos, qayta urinib ko'ring.",
"Failed to load image. Please try uploading again.": "Rasm yuklanmadi. Iltimos, qayta yuklashga urinib ko'ring.",
"File size exceeds the 10 MB limit.": "Fayl hajmi 10 MB chegarasidan oshib ketdi."
} }

5
src/translations/locales/zh.json

@ -2116,5 +2116,8 @@
"8 min": "8分钟", "8 min": "8分钟",
"10 min": "10分钟", "10 min": "10分钟",
"15 min": "15分钟", "15 min": "15分钟",
"16 min": "16分钟"
"16 min": "16分钟",
"Photo upload failed. Please try again.": "照片上传失败。请再试一次。",
"Failed to load image. Please try uploading again.": "图片加载失败。请重试上传。",
"File size exceeds the 10 MB limit.": "文件大小超过 10 MB 限制。"
} }
Loading…
Cancel
Save