diff --git a/app/components/store/StoreForm.tsx b/app/components/store/StoreForm.tsx index a9bea6d..2785f44 100644 --- a/app/components/store/StoreForm.tsx +++ b/app/components/store/StoreForm.tsx @@ -9,6 +9,7 @@ import AppSelectBox from "../AppSelectBox"; import { Button } from "../ui/button"; import { FormField } from "../ui/FormField"; import { ImageUpload } from "./ImageUpload"; +import { convertHeicToJpeg } from "~/utils/convert-heic"; import { ImageUploadDrawer } from "./ImageUploadDrawer"; import { Textarea } from "../ui/textarea"; import { useToast } from "../../hooks/use-toast"; @@ -294,9 +295,13 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) { !validationErrors.instagramId; // Handle image selection - const handleImageSelect = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { + const handleImageSelect = async ( + event: React.ChangeEvent + ) => { + const picked = event.target.files?.[0]; + if (picked) { + // iPhone logos are often HEIC, which most browsers can't preview; convert. + const file = await convertHeicToJpeg(picked); if (imagePreviewUrl) { URL.revokeObjectURL(imagePreviewUrl); } diff --git a/app/routes/store.add.manual.$storeId.tsx b/app/routes/store.add.manual.$storeId.tsx index a1bcaa9..22661f9 100644 --- a/app/routes/store.add.manual.$storeId.tsx +++ b/app/routes/store.add.manual.$storeId.tsx @@ -3,6 +3,7 @@ import { X, Loader } from "lucide-react"; import HeaderWithSupport from "../components/HeaderWithSupport"; import { useSearchParams } from "react-router-dom"; import { safeGoBack } from "~/utils/helpers"; +import { convertHeicFiles } from "~/utils/convert-heic"; import { useRef, useState, useEffect } from "react"; import { Button } from "../components/ui/button"; import { ImageUploadDrawer } from "~/components/store/ImageUploadDrawer"; @@ -709,11 +710,16 @@ export default function AddProduct() { }; // Handle image selection - const handleImageSelect = (event: React.ChangeEvent) => { + const handleImageSelect = async ( + event: React.ChangeEvent + ) => { const files = event.target.files; if (files && files.length > 0) { - // Convert FileList to Array and process all files - const newImages = Array.from(files).map((file) => ({ + // iPhone photos are HEIC, which most browsers can't preview/crop. + // Convert to JPEG up front so preview, the crop editor, and upload work. + const converted = await convertHeicFiles(Array.from(files)); + + const newImages = converted.map((file) => ({ file, url: URL.createObjectURL(file), isEdited: false, diff --git a/app/utils/convert-heic.ts b/app/utils/convert-heic.ts new file mode 100644 index 0000000..54ec324 --- /dev/null +++ b/app/utils/convert-heic.ts @@ -0,0 +1,66 @@ +// Client-side HEIC/HEIF -> JPEG conversion. +// +// iPhones store photos as HEIC. Most browsers (everything except Safari/WebKit) +// can't decode HEIC in /canvas, so previewing and cropping a freshly +// picked HEIC file fails. We convert to JPEG right after selection so the rest +// of the upload flow (preview, ImageEditor crop, upload) works everywhere. +// +// heic2any is browser-only (uses a wasm libheif build), so it must be imported +// dynamically inside the handler — never at module top level (SSR would break). + +const HEIC_EXT_RE = /\.(heic|heif)$/i; + +/** True if the file looks like HEIC/HEIF by MIME type or, when the browser + * reports no/!generic type (common on iOS), by file extension. */ +export function isHeic(file: File): boolean { + const type = (file.type || "").toLowerCase(); + if ( + type === "image/heic" || + type === "image/heif" || + type === "image/heic-sequence" || + type === "image/heif-sequence" + ) { + return true; + } + if (type === "" || type === "application/octet-stream") { + return HEIC_EXT_RE.test(file.name); + } + return false; +} + +/** + * Returns a JPEG File when given a HEIC/HEIF file; otherwise returns the + * original file unchanged. Never throws — on conversion failure it falls back + * to the original file (the backend's pillow-heif may still handle it). + */ +export async function convertHeicToJpeg( + file: File, + quality = 0.92 +): Promise { + if (typeof window === "undefined" || !isHeic(file)) return file; + + try { + const heic2any = (await import("heic2any")).default; + const result = (await heic2any({ + blob: file, + toType: "image/jpeg", + quality, + })) as Blob | Blob[]; + + const blob = Array.isArray(result) ? result[0] : result; + const baseName = file.name.replace(HEIC_EXT_RE, "") || "image"; + + return new File([blob], `${baseName}.jpg`, { + type: "image/jpeg", + lastModified: Date.now(), + }); + } catch (e) { + console.error("HEIC->JPEG conversion failed; using original file", e); + return file; + } +} + +/** Convert any HEIC/HEIF files in a list, leaving others untouched. */ +export async function convertHeicFiles(files: File[]): Promise { + return Promise.all(files.map((f) => convertHeicToJpeg(f))); +} diff --git a/package-lock.json b/package-lock.json index 1cd2993..af63b19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "cmdk": "^1.1.1", "embla-carousel-react": "^8.5.2", "framer-motion": "^12.4.10", + "heic2any": "^0.0.4", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", "i18next-http-backend": "^3.0.2", @@ -8839,6 +8840,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/heic2any": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz", + "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", + "license": "MIT" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index f75870d..9c1b1ec 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "cmdk": "^1.1.1", "embla-carousel-react": "^8.5.2", "framer-motion": "^12.4.10", + "heic2any": "^0.0.4", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", "i18next-http-backend": "^3.0.2",