refactor: Made modal a reusable component
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s

This commit is contained in:
2026-06-06 22:36:19 +05:30
parent f95d1f3028
commit 77349f2b90
5 changed files with 272 additions and 481 deletions

View File

@@ -1,59 +1,12 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { ROAST_TYPES, inputCls, labelCls } from "../constants";
import Modal from "./Modal";
export default function BeanForm({ onSave, onClose, initial }) {
const [form, setForm] = useState(initial || { name: "", roastery: "", roastDate: "", roastType: "", image: "", tastingNotes: "" });
const [active, setActive] = useState(false);
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const canSave = form.name.trim().length > 0;
useEffect(() => {
// Prevent background scroll when mounted
document.body.style.overflow = "hidden";
const raf = requestAnimationFrame(() => setActive(true));
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
if (typeof callback === "function") {
callback();
} else {
onClose();
}
}, 200);
};
const handleDragStart = (clientY) => {
setIsDragging(true);
setStartY(clientY);
};
const handleDragMove = (clientY) => {
if (!isDragging) return;
const deltaY = clientY - startY;
if (deltaY > 0) {
setDragY(deltaY);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragY > 100) {
handleClose();
} else {
setDragY(0);
}
};
const handleImage = (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -64,71 +17,45 @@ export default function BeanForm({ onSave, onClose, initial }) {
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8`}
onClick={e => e.stopPropagation()}
style={{
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
<div
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">{initial ? "Edit Bean" : "Add Bean"}</div>
<div className="mb-4"><label className={labelCls}>Bean Name *</label>
<input className={inputCls} placeholder="e.g. Ethiopia Yirgacheffe" value={form.name} onChange={e => set("name", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Roastery</label>
<input className={inputCls} placeholder="e.g. Blue Tokai" value={form.roastery} onChange={e => set("roastery", e.target.value)} /></div>
<div className="flex gap-2.5 mb-4">
<div className="flex-1"><label className={labelCls}>Roast Date</label>
<input className={inputCls} type="date" value={form.roastDate} onChange={e => set("roastDate", e.target.value)} /></div>
<div className="flex-1"><label className={labelCls}>Roast Type</label>
<select className={inputCls} value={form.roastType} onChange={e => set("roastType", e.target.value)}>
<option value="">Select</option>
{ROAST_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
</select></div>
</div>
<div className="mb-4">
<label className={labelCls}>Bean Photo</label>
<div className={`relative border-2 rounded-lg text-center cursor-pointer transition-all overflow-hidden ${form.image ? "border-[#E8DFD3] dark:border-[#3B2217] p-0" : "border-dashed border-[#E8DFD3] dark:border-[#3B2217] p-5 hover:border-[#8B6914] dark:hover:border-[#D4A325]"}`}>
{form.image ? (
<><img src={form.image} alt="Bean" className="w-full h-40 object-cover rounded-lg block" />
<button className="absolute top-2 right-2 w-7 h-7 rounded-full bg-[rgba(44,24,16,0.7)] dark:bg-[rgba(250,246,241,0.8)] text-white dark:text-[#2C1810] border-none text-sm cursor-pointer flex items-center justify-center font-bold"
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
) : (<><div className="text-3xl mb-1.5 opacity-40 dark:opacity-75">📷</div><div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Tap to upload a photo</div></>)}
<input type="file" accept="image/*" onChange={handleImage} className="absolute inset-0 opacity-0 cursor-pointer" />
<Modal onClose={onClose}>
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">{initial ? "Edit Bean" : "Add Bean"}</div>
<div className="mb-4"><label className={labelCls}>Bean Name *</label>
<input className={inputCls} placeholder="e.g. Ethiopia Yirgacheffe" value={form.name} onChange={e => set("name", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Roastery</label>
<input className={inputCls} placeholder="e.g. Blue Tokai" value={form.roastery} onChange={e => set("roastery", e.target.value)} /></div>
<div className="flex gap-2.5 mb-4">
<div className="flex-1"><label className={labelCls}>Roast Date</label>
<input className={inputCls} type="date" value={form.roastDate} onChange={e => set("roastDate", e.target.value)} /></div>
<div className="flex-1"><label className={labelCls}>Roast Type</label>
<select className={inputCls} value={form.roastType} onChange={e => set("roastType", e.target.value)}>
<option value="">Select</option>
{ROAST_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
</select></div>
</div>
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Max 2 MB · JPG, PNG, or WebP</div>
</div>
<div className="mb-4"><label className={labelCls}>Tasting Notes</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`} placeholder="e.g. Citrus, dark chocolate, floral…" value={form.tastingNotes || ""} onChange={e => set("tastingNotes", e.target.value)} />
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Flavour profile from the bag or your own impressions</div></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer mt-2"
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
onClick={() => { if (canSave) handleClose(() => onSave(form)); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</div>
</div>
<div className="mb-4">
<label className={labelCls}>Bean Photo</label>
<div className={`relative border-2 rounded-lg text-center cursor-pointer transition-all overflow-hidden ${form.image ? "border-[#E8DFD3] dark:border-[#3B2217] p-0" : "border-dashed border-[#E8DFD3] dark:border-[#3B2217] p-5 hover:border-[#8B6914] dark:hover:border-[#D4A325]"}`}>
{form.image ? (
<><img src={form.image} alt="Bean" className="w-full h-40 object-cover rounded-lg block" />
<button className="absolute top-2 right-2 w-7 h-7 rounded-full bg-[rgba(44,24,16,0.7)] dark:bg-[rgba(250,246,241,0.8)] text-white dark:text-[#2C1810] border-none text-sm cursor-pointer flex items-center justify-center font-bold"
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
) : (<><div className="text-3xl mb-1.5 opacity-40 dark:opacity-75">📷</div><div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Tap to upload a photo</div></>)}
<input type="file" accept="image/*" onChange={handleImage} className="absolute inset-0 opacity-0 cursor-pointer" />
</div>
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Max 2 MB · JPG, PNG, or WebP</div>
</div>
<div className="mb-4"><label className={labelCls}>Tasting Notes</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`} placeholder="e.g. Citrus, dark chocolate, floral…" value={form.tastingNotes || ""} onChange={e => set("tastingNotes", e.target.value)} />
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Flavour profile from the bag or your own impressions</div></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer mt-2"
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
onClick={() => { if (canSave) close(() => onSave(form)); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</>
)}
</Modal>
);
}

View File

@@ -1,100 +1,27 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { METHODS, METHOD_LABELS, METHOD_ICONS, METHOD_COLORS, inputCls, labelCls } from "../constants";
import Modal from "./Modal";
export default function BrewForm({ beans, onSave, onClose }) {
const [method, setMethod] = useState("pourover");
const [form, setForm] = useState({ beanId: beans[0]?.id || "" });
const [active, setActive] = useState(false);
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
useEffect(() => {
// Prevent background scroll when mounted
document.body.style.overflow = "hidden";
const raf = requestAnimationFrame(() => setActive(true));
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
if (typeof callback === "function") {
callback();
} else {
onClose();
}
}, 200);
};
const handleDragStart = (clientY) => {
setIsDragging(true);
setStartY(clientY);
};
const handleDragMove = (clientY) => {
if (!isDragging) return;
const deltaY = clientY - startY;
if (deltaY > 0) {
setDragY(deltaY);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragY > 100) {
handleClose();
} else {
setDragY(0);
}
};
if (beans.length === 0) {
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8`}
onClick={e => e.stopPropagation()}
style={{
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
<div
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">🫘</div>
<h3 className="text-base mb-1.5 text-[#6B5744] dark:text-[#FAF6F1]">No beans yet</h3>
<p className="text-[13px] leading-relaxed">Add a bean to your library first.</p>
</div>
<button className="w-full py-3.5 border border-[#E8DFD3] dark:border-[#3B2217] rounded-xl text-sm font-semibold text-[#6B5744] dark:text-[#C8B9A6] bg-transparent cursor-pointer hover:bg-[#F3EDE4] dark:hover:bg-[#2C1810]" onClick={() => handleClose()}>Close</button>
</div>
</div>
<Modal onClose={onClose}>
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">🫘</div>
<h3 className="text-base mb-1.5 text-[#6B5744] dark:text-[#FAF6F1]">No beans yet</h3>
<p className="text-[13px] leading-relaxed">Add a bean to your library first.</p>
</div>
<button className="w-full py-3.5 border border-[#E8DFD3] dark:border-[#3B2217] rounded-xl text-sm font-semibold text-[#6B5744] dark:text-[#C8B9A6] bg-transparent cursor-pointer hover:bg-[#F3EDE4] dark:hover:bg-[#2C1810]" onClick={() => close()}>Close</button>
</>
)}
</Modal>
);
}
@@ -122,71 +49,45 @@ export default function BrewForm({ beans, onSave, onClose }) {
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8`}
onClick={e => e.stopPropagation()}
style={{
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
<div
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="flex gap-1.5 mb-5">
{METHODS.map(m => (
<button key={m}
className={`flex-1 py-3 px-2 border-2 bg-white dark:bg-[#22120B] rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current" : "border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6]"}`}
style={{ color: method === m ? METHOD_COLORS[m] : undefined }}
onClick={() => setMethod(m)}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>{METHOD_LABELS[m]}
</button>
))}
</div>
<div className="mb-4"><label className={labelCls}>Bean *</label>
<select className={inputCls} value={form.beanId} onChange={e => set("beanId", e.target.value)}>
{beans.map(b => <option key={b.id} value={b.id} className="dark:bg-[#150B07]">{b.name}{b.roastery ? `${b.roastery}` : ""}</option>)}
</select></div>
<div className="flex flex-wrap gap-2.5 mb-4">
{fields[method].map(f => (
<div key={f.key} className="flex-1 min-w-[45%]">
<label className={labelCls}>{f.label}</label>
<input className={inputCls} type={f.type || "text"} placeholder={f.placeholder}
value={form[f.key] || ""} onChange={e => set(f.key, e.target.value)} />
{f.hint && <div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">{f.hint}</div>}
</div>
))}
</div>
<div className="mb-4"><label className={labelCls}>{method === "pourover" ? "Recipe Details" : "Notes"}</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`}
placeholder="Describe your recipe, technique, or anything notable…"
value={form.recipeDetails || ""} onChange={e => set("recipeDetails", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Taste Notes</label>
<input className={inputCls} placeholder="e.g. citrus, chocolate, floral"
value={form.tasteNotes || ""} onChange={e => set("tasteNotes", e.target.value)} /></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
onClick={() => handleClose(() => onSave({ ...form, method }))}>Save Brew Log</button>
</div>
</div>
<Modal onClose={onClose}>
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="flex gap-1.5 mb-5">
{METHODS.map(m => (
<button key={m}
className={`flex-1 py-3 px-2 border-2 bg-white dark:bg-[#22120B] rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current" : "border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6]"}`}
style={{ color: method === m ? METHOD_COLORS[m] : undefined }}
onClick={() => setMethod(m)}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>{METHOD_LABELS[m]}
</button>
))}
</div>
<div className="mb-4"><label className={labelCls}>Bean *</label>
<select className={inputCls} value={form.beanId} onChange={e => set("beanId", e.target.value)}>
{beans.map(b => <option key={b.id} value={b.id} className="dark:bg-[#150B07]">{b.name}{b.roastery ? `${b.roastery}` : ""}</option>)}
</select></div>
<div className="flex flex-wrap gap-2.5 mb-4">
{fields[method].map(f => (
<div key={f.key} className="flex-1 min-w-[45%]">
<label className={labelCls}>{f.label}</label>
<input className={inputCls} type={f.type || "text"} placeholder={f.placeholder}
value={form[f.key] || ""} onChange={e => set(f.key, e.target.value)} />
{f.hint && <div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">{f.hint}</div>}
</div>
))}
</div>
<div className="mb-4"><label className={labelCls}>{method === "pourover" ? "Recipe Details" : "Notes"}</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`}
placeholder="Describe your recipe, technique, or anything notable…"
value={form.recipeDetails || ""} onChange={e => set("recipeDetails", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Taste Notes</label>
<input className={inputCls} placeholder="e.g. citrus, chocolate, floral"
value={form.tasteNotes || ""} onChange={e => set("tasteNotes", e.target.value)} /></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
onClick={() => close(() => onSave({ ...form, method }))}>Save Brew Log</button>
</>
)}
</Modal>
);
}

View File

@@ -1,108 +1,33 @@
import { useState, useEffect } from "react";
import Modal from "./Modal";
export default function CreateModal({ onClose, onAddBean, onAddBrew }) {
const [active, setActive] = useState(false);
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
useEffect(() => {
// Prevent background scroll when mounted
document.body.style.overflow = "hidden";
// Trigger transition shortly after mounting
const raf = requestAnimationFrame(() => setActive(true));
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
onClose();
if (typeof callback === "function") {
callback();
}
}, 200);
};
const handleDragStart = (clientY) => {
setIsDragging(true);
setStartY(clientY);
};
const handleDragMove = (clientY) => {
if (!isDragging) return;
const deltaY = clientY - startY;
if (deltaY > 0) {
setDragY(deltaY);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragY > 100) {
handleClose();
} else {
setDragY(0);
}
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[28px] w-full max-w-[480px] px-5 pb-10 transition-transform duration-200 ease-in-out`}
onClick={e => e.stopPropagation()}
style={{
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
<div
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-6">What would you like to add?</div>
<div className="flex flex-col gap-3">
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => handleClose(onAddBrew)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0"></div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Log a Brew</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Record a brewing session with recipe details</div>
</div>
</button>
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => handleClose(onAddBean)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Add Bean</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Add a new coffee bean to your library</div>
</div>
</button>
</div>
</div>
</div>
<Modal onClose={onClose} padding="px-5 pb-10">
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-6">What would you like to add?</div>
<div className="flex flex-col gap-3">
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => close(onAddBrew)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0"></div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Log a Brew</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Record a brewing session with recipe details</div>
</div>
</button>
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => close(onAddBean)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Add Bean</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Add a new coffee bean to your library</div>
</div>
</button>
</div>
</>
)}
</Modal>
);
}

View File

@@ -1,11 +1,8 @@
import { useState, useEffect } from "react";
import Modal from "./Modal";
export default function IosPromptModal() {
const [show, setShow] = useState(false);
const [active, setActive] = useState(false);
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
useEffect(() => {
// Detect if user is on iOS or iPadOS
@@ -21,124 +18,64 @@ export default function IosPromptModal() {
if (isIOS && !isStandalone && !isDismissed) {
setShow(true);
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}
}, []);
useEffect(() => {
if (show) {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}
}, [show]);
if (!show) return null;
const handleClose = () => {
setActive(false);
sessionStorage.setItem("ios-prompt-dismissed", "true");
setTimeout(() => {
setShow(false);
}, 200);
};
const handleDragStart = (clientY) => {
setIsDragging(true);
setStartY(clientY);
};
const handleDragMove = (clientY) => {
if (!isDragging) return;
const deltaY = clientY - startY;
if (deltaY > 0) {
setDragY(deltaY);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragY > 100) {
handleClose();
} else {
setDragY(0);
}
setShow(false);
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[200] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={handleClose}
<Modal
onClose={handleClose}
zIndex="z-[200]"
className="relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)] dark:shadow-none"
padding="px-6 pb-8 pt-4"
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[28px] w-full max-w-[480px] px-6 pb-8 pt-4 relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)] dark:shadow-none transition-transform duration-200 ease-in-out`}
onClick={e => e.stopPropagation()}
style={{
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
{/* Handle bar */}
<div
className="w-full py-4 -mt-4 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
</div>
{/* Close Button */}
<button
onClick={handleClose}
className="absolute right-5 top-5 w-8 h-8 flex items-center justify-center rounded-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] transition-colors cursor-pointer text-lg font-semibold"
aria-label="Close"
>
&times;
</button>
{(close) => (
<>
{/* Close Button */}
<button
onClick={() => close()}
className="absolute right-5 top-5 w-8 h-8 flex items-center justify-center rounded-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] transition-colors cursor-pointer text-lg font-semibold"
aria-label="Close"
>
&times;
</button>
{/* Content */}
<div className="flex flex-col items-center text-center">
{/* Brew Logo */}
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-[22%] flex items-center justify-center overflow-hidden shadow-sm mb-3">
<img src="/icon-192.png" alt="Brew Logo" className="w-full h-full object-cover" />
{/* Content */}
<div className="flex flex-col items-center text-center">
{/* Brew Logo */}
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-[22%] flex items-center justify-center overflow-hidden shadow-sm mb-3">
<img src="/icon-192.png" alt="Brew Logo" className="w-full h-full object-cover" />
</div>
{/* Brew Text */}
<div className="font-serif text-2xl font-bold text-[#2C1810] dark:text-[#FAF6F1] mb-3">Brew</div>
{/* Description */}
<p className="text-sm text-[#6B5744] dark:text-[#C8B9A6] leading-relaxed mb-6 px-4">
Open this page on safari and then install this app to your homescreen for a better experience.
</p>
{/* Instruction */}
<div className="w-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-4 flex items-center justify-center gap-3">
<span className="text-sm text-[#2C1810] dark:text-[#FAF6F1] font-medium flex items-center justify-center flex-wrap">
Tap
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D4A325" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="inline-block mx-1.5 align-middle">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
then <span className="font-bold text-[#8B6914] dark:text-[#D4A325] ml-1">"Add to Home Screen"</span>
</span>
</div>
</div>
{/* Brew Text */}
<div className="font-serif text-2xl font-bold text-[#2C1810] dark:text-[#FAF6F1] mb-3">Brew</div>
{/* Description */}
<p className="text-sm text-[#6B5744] dark:text-[#C8B9A6] leading-relaxed mb-6 px-4">
Open this page on safari and then install this app to your homescreen for a better experience.
</p>
{/* Instruction */}
<div className="w-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-4 flex items-center justify-center gap-3">
<span className="text-sm text-[#2C1810] dark:text-[#FAF6F1] font-medium flex items-center justify-center flex-wrap">
Tap
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D4A325" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="inline-block mx-1.5 align-middle">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
then <span className="font-bold text-[#8B6914] dark:text-[#D4A325] ml-1">"Add to Home Screen"</span>
</span>
</div>
</div>
</div>
</div>
</>
)}
</Modal>
);
}

101
src/components/Modal.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from "react";
export default function Modal({
onClose,
children,
zIndex = "z-[100]",
maxWidth = "480px",
maxHeight = "88vh",
padding = "px-5 pb-8",
className = ""
}) {
const [active, setActive] = useState(false);
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
useEffect(() => {
// Prevent background scroll when mounted
document.body.style.overflow = "hidden";
// Trigger transition shortly after mounting
const raf = requestAnimationFrame(() => setActive(true));
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
onClose();
if (typeof callback === "function") {
callback();
}
}, 200);
};
const handleDragStart = (clientY) => {
setIsDragging(true);
setStartY(clientY);
};
const handleDragMove = (clientY) => {
if (!isDragging) return;
const deltaY = clientY - startY;
if (deltaY > 0) {
setDragY(deltaY);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragY > 100) {
handleClose();
} else {
setDragY(0);
}
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] flex items-end justify-center transition-opacity duration-200 ease-in-out ${zIndex} ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-brew-bg dark:bg-[#150B07] rounded-t-3xl w-full transition-transform duration-200 ease-in-out ${padding} ${className}`}
onClick={e => e.stopPropagation()}
style={{
maxWidth,
maxHeight,
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
{/* Grab Handle */}
<div
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-brew-border dark:bg-[#3B2217] rounded" />
</div>
{/* Content */}
{typeof children === "function" ? children(handleClose) : children}
</div>
</div>
);
}