477 lines
16 KiB
JavaScript
477 lines
16 KiB
JavaScript
import { useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { formatChf } from "../shop/money";
|
|
import { useShop } from "../shop/useShop";
|
|
import "./ShopDrawer.css";
|
|
|
|
const paymentMethods = [
|
|
{ key: "Bill", badge: "BILL", label: "Bill" },
|
|
{ key: "Card", badge: "CARD", label: "Card" },
|
|
{ key: "Twint", badge: "TW", label: "Twint" },
|
|
{ key: "PayPal", badge: "PP", label: "PayPal" },
|
|
];
|
|
|
|
const notificationLabels = [
|
|
["drops_enabled", "New Drops"],
|
|
["restocks_enabled", "Restocks"],
|
|
["small_batch_enabled", "Small Batch Releases"],
|
|
["discovery_enabled", "Discovery Set Updates"],
|
|
];
|
|
|
|
function Field({ label, value, onChange, type = "text", readOnly = false }) {
|
|
return (
|
|
<label className="shop-field">
|
|
<span>{label}</span>
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
readOnly={readOnly}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function AuthPanel() {
|
|
const { busy, error, login, register } = useShop();
|
|
const [mode, setMode] = useState("login");
|
|
const [form, setForm] = useState({
|
|
first_name: "",
|
|
surname: "",
|
|
email: "",
|
|
password: "",
|
|
});
|
|
|
|
const update = (key, value) => setForm((current) => ({ ...current, [key]: value }));
|
|
|
|
const submit = (event) => {
|
|
event.preventDefault();
|
|
if (mode === "login") {
|
|
login({ email: form.email, password: form.password }).catch(() => {});
|
|
return;
|
|
}
|
|
register(form).catch(() => {});
|
|
};
|
|
|
|
return (
|
|
<form className="drawer-section auth-panel" onSubmit={submit}>
|
|
<span className="drawer-kicker">{mode === "login" ? "LOGIN" : "REGISTER"}</span>
|
|
<h2>{mode === "login" ? "Welcome back." : "Create your atmos account."}</h2>
|
|
|
|
{mode === "register" && (
|
|
<div className="drawer-grid drawer-grid--two">
|
|
<Field label="Name" value={form.first_name} onChange={(value) => update("first_name", value)} />
|
|
<Field label="Surname" value={form.surname} onChange={(value) => update("surname", value)} />
|
|
</div>
|
|
)}
|
|
|
|
<Field label="Email address" value={form.email} onChange={(value) => update("email", value)} />
|
|
<Field
|
|
label="Password"
|
|
type="password"
|
|
value={form.password}
|
|
onChange={(value) => update("password", value)}
|
|
/>
|
|
|
|
{error && <p className="drawer-error">{error}</p>}
|
|
|
|
<button className="drawer-primary" type="submit" disabled={busy}>
|
|
{mode === "login" ? "Login" : "Register"}
|
|
</button>
|
|
|
|
<button
|
|
className="drawer-secondary"
|
|
type="button"
|
|
onClick={() => setMode((current) => (current === "login" ? "register" : "login"))}
|
|
>
|
|
{mode === "login" ? "Create account" : "Use existing account"}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function CartPanel() {
|
|
const {
|
|
cart,
|
|
checkout,
|
|
removeCartItem,
|
|
updateCartQuantity,
|
|
busy,
|
|
error,
|
|
user,
|
|
} = useShop();
|
|
const [address, setAddress] = useState(() => ({
|
|
street_name: user?.street_name || "",
|
|
house_number: user?.house_number || "",
|
|
zip_code: user?.zip_code || "",
|
|
city: user?.city || "",
|
|
}));
|
|
const [paymentMethod, setPaymentMethod] = useState("Bill");
|
|
|
|
const updateAddress = (key, value) =>
|
|
setAddress((current) => ({ ...current, [key]: value }));
|
|
|
|
const submit = (event) => {
|
|
event.preventDefault();
|
|
checkout({ ...address, payment_method: paymentMethod }).catch(() => {});
|
|
};
|
|
|
|
return (
|
|
<form className="drawer-stack" onSubmit={submit}>
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">CART</span>
|
|
{cart.items.length === 0 ? (
|
|
<p className="drawer-muted">Your cart is empty.</p>
|
|
) : (
|
|
<div className="cart-items">
|
|
{cart.items.map((item) => (
|
|
<article className="cart-item" key={item.product_id}>
|
|
<div>
|
|
<h3>{item.product.name}</h3>
|
|
<p>
|
|
{item.product.size_label} · {formatChf(item.product.price_cents)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="cart-controls">
|
|
<button
|
|
type="button"
|
|
onClick={() => updateCartQuantity(item.product_id, item.quantity - 1)}
|
|
>
|
|
-
|
|
</button>
|
|
<span>{item.quantity}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => updateCartQuantity(item.product_id, item.quantity + 1)}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
className="cart-remove"
|
|
type="button"
|
|
onClick={() => removeCartItem(item.product_id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">SHIPPING</span>
|
|
<div className="drawer-grid drawer-grid--two">
|
|
<Field label="Street Name" value={address.street_name} onChange={(value) => updateAddress("street_name", value)} />
|
|
<Field label="House Number" value={address.house_number} onChange={(value) => updateAddress("house_number", value)} />
|
|
<Field label="ZIP Code" value={address.zip_code} onChange={(value) => updateAddress("zip_code", value)} />
|
|
<Field label="City" value={address.city} onChange={(value) => updateAddress("city", value)} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">PAYMENT</span>
|
|
<div className="payment-grid">
|
|
{paymentMethods.map((method) => (
|
|
<button
|
|
type="button"
|
|
className={`payment-card ${paymentMethod === method.key ? "active" : ""}`}
|
|
key={method.key}
|
|
onClick={() => setPaymentMethod(method.key)}
|
|
>
|
|
<span>{method.badge}</span>
|
|
<strong>{method.label}</strong>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="drawer-section totals-box">
|
|
<div>
|
|
<span>Subtotal</span>
|
|
<strong>{formatChf(cart.subtotal_cents)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Rabatte</span>
|
|
<strong>-{formatChf(cart.discount_cents)}</strong>
|
|
</div>
|
|
{cart.discounts?.length > 0 && (
|
|
<div className="discount-explainer">
|
|
<span>Applied automatically</span>
|
|
{cart.discounts.map((discount) => (
|
|
<p key={`${discount.type}-${discount.creditId}`}>
|
|
<strong>{formatChf(discount.amount_cents)}</strong>
|
|
{" - "}
|
|
{discount.type === "discovery"
|
|
? "Discovery Set credit for a full-size bottle"
|
|
: `Sample credit for ${discount.slug}`}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="total-row">
|
|
<span>Total</span>
|
|
<strong>{formatChf(cart.total_cents)}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
{error && <p className="drawer-error">{error}</p>}
|
|
|
|
<button className="drawer-primary" type="submit" disabled={busy || cart.items.length === 0}>
|
|
Checkout
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function RequirementRow({ label, met, children }) {
|
|
return (
|
|
<div className="requirement-row">
|
|
<span>{label}</span>
|
|
<strong className={met ? "met" : ""}>{children || (met ? "met" : "open")}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfilePanel() {
|
|
const {
|
|
busy,
|
|
error,
|
|
logout,
|
|
orders,
|
|
removeProductSubscription,
|
|
updateNotifications,
|
|
updateProfile,
|
|
user,
|
|
closePanel,
|
|
} = useShop();
|
|
const [editing, setEditing] = useState(false);
|
|
const [form, setForm] = useState(user || {});
|
|
|
|
const update = (key, value) => setForm((current) => ({ ...current, [key]: value }));
|
|
const notifications = user?.notifications || {};
|
|
const restockSubscriptions = (user?.productSubscriptions || []).filter(
|
|
(subscription) => subscription.type === "restock"
|
|
);
|
|
const loyalty = user?.loyaltyStatus || {
|
|
hasDiscoverySet: false,
|
|
hasFullSize: false,
|
|
purchases: 0,
|
|
spent_cents: 0,
|
|
unlocked: false,
|
|
};
|
|
|
|
const save = () => {
|
|
updateProfile({
|
|
first_name: form.first_name,
|
|
surname: form.surname,
|
|
street_name: form.street_name,
|
|
house_number: form.house_number,
|
|
zip_code: form.zip_code,
|
|
city: form.city,
|
|
birthdate: form.birthdate,
|
|
})
|
|
.then(() => setEditing(false))
|
|
.catch(() => {});
|
|
};
|
|
|
|
const togglePreference = (key) => {
|
|
updateNotifications({ ...notifications, [key]: !notifications[key] }).catch(() => {});
|
|
};
|
|
|
|
return (
|
|
<div className="drawer-stack">
|
|
<section className="drawer-section profile-head">
|
|
<span className="drawer-kicker">PROFILE</span>
|
|
<h2>Hi, {user.first_name}</h2>
|
|
<button className="drawer-secondary" type="button" onClick={logout}>
|
|
Logout
|
|
</button>
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<div className="profile-section-header">
|
|
<span className="drawer-kicker">PROFILE INFORMATION</span>
|
|
{!editing && (
|
|
<button className="drawer-secondary" type="button" onClick={() => setEditing(true)}>
|
|
Profil bearbeiten
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{editing ? (
|
|
<>
|
|
<div className="drawer-grid drawer-grid--two">
|
|
<Field label="Name" value={form.first_name || ""} onChange={(value) => update("first_name", value)} />
|
|
<Field label="Surname" value={form.surname || ""} onChange={(value) => update("surname", value)} />
|
|
<Field label="Street Name" value={form.street_name || ""} onChange={(value) => update("street_name", value)} />
|
|
<Field label="House Number" value={form.house_number || ""} onChange={(value) => update("house_number", value)} />
|
|
<Field label="ZIP Code" value={form.zip_code || ""} onChange={(value) => update("zip_code", value)} />
|
|
<Field label="City" value={form.city || ""} onChange={(value) => update("city", value)} />
|
|
<Field label="Birthdate" type="date" value={form.birthdate || ""} onChange={(value) => update("birthdate", value)} />
|
|
</div>
|
|
<div className="drawer-actions">
|
|
<button className="drawer-primary" type="button" disabled={busy} onClick={save}>
|
|
Save profile
|
|
</button>
|
|
<button
|
|
className="drawer-secondary"
|
|
type="button"
|
|
onClick={() => {
|
|
setForm(user);
|
|
setEditing(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="profile-read-grid">
|
|
<ReadBlock label="Name" value={user.first_name} />
|
|
<ReadBlock label="Surname" value={user.surname} />
|
|
<ReadBlock label="Street Name" value={user.street_name || "-"} />
|
|
<ReadBlock label="House Number" value={user.house_number || "-"} />
|
|
<ReadBlock label="ZIP Code" value={user.zip_code || "-"} />
|
|
<ReadBlock label="City" value={user.city || "-"} />
|
|
<ReadBlock label="Birthdate" value={user.birthdate || "-"} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">DISCOUNT STATUS</span>
|
|
<div className="status-box">{user.discoveryStatus}</div>
|
|
{user.sampleCredits?.length > 0 && (
|
|
<div className="sample-credit-list">
|
|
{user.sampleCredits.map((credit) => (
|
|
<span key={`${credit.slug}-${credit.created_at}`}>
|
|
{credit.slug}: {credit.status}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">DROP / RESTOCK PREFERENCES</span>
|
|
<div className="toggle-grid">
|
|
{notificationLabels.map(([key, label]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className={`pref-toggle ${notifications[key] ? "active" : ""}`}
|
|
onClick={() => togglePreference(key)}
|
|
>
|
|
<span>{label}</span>
|
|
<strong>{notifications[key] ? "Active" : "Inactive"}</strong>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="subscription-list">
|
|
<span className="subscription-list-title">Subscribed Restocks</span>
|
|
{restockSubscriptions.length === 0 ? (
|
|
<p className="drawer-muted">No product-specific restock updates yet.</p>
|
|
) : (
|
|
restockSubscriptions.map((subscription) => (
|
|
<article className="subscription-row" key={subscription.id}>
|
|
<div>
|
|
<strong>{subscription.name}</strong>
|
|
<span>{subscription.size_label}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeProductSubscription(subscription.id).catch(() => {})}
|
|
>
|
|
Delete
|
|
</button>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">SMALL BATCH ACCESS</span>
|
|
<div className="status-box">{loyalty.unlocked ? "Unlocked" : "Locked"}</div>
|
|
<div className="requirements">
|
|
<RequirementRow label="Discovery Set" met={loyalty.hasDiscoverySet} />
|
|
<RequirementRow label="Full Size" met={loyalty.hasFullSize} />
|
|
<RequirementRow label="Purchases" met={loyalty.purchases >= 3}>
|
|
{loyalty.purchases}/3 Purchases
|
|
</RequirementRow>
|
|
<RequirementRow label="Spend" met={loyalty.spent_cents > 50000}>
|
|
{formatChf(loyalty.spent_cents)} / CHF 500+
|
|
</RequirementRow>
|
|
</div>
|
|
<Link
|
|
className="drawer-primary drawer-link-primary"
|
|
to="/small-batch"
|
|
onClick={closePanel}
|
|
>
|
|
Small Batch ansehen
|
|
</Link>
|
|
</section>
|
|
|
|
<section className="drawer-section">
|
|
<span className="drawer-kicker">PURCHASES</span>
|
|
{orders.length === 0 ? (
|
|
<p className="drawer-muted">No orders yet.</p>
|
|
) : (
|
|
<div className="order-list">
|
|
{orders.map((order) => (
|
|
<article className="order-card" key={order.id}>
|
|
<div>
|
|
<strong>Order #{order.id}</strong>
|
|
<span>{new Date(order.created_at).toLocaleDateString("de-CH")}</span>
|
|
</div>
|
|
<p>{order.items.map((item) => `${item.quantity} x ${item.product.name}`).join(", ")}</p>
|
|
<strong>{formatChf(order.total_cents)}</strong>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{error && <p className="drawer-error">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReadBlock({ label, value }) {
|
|
return (
|
|
<div className="read-block">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ShopDrawer() {
|
|
const { closePanel, panelOpen, panelType, user } = useShop();
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`drawer-backdrop ${panelOpen ? "open" : ""}`}
|
|
onClick={closePanel}
|
|
/>
|
|
<aside className={`shop-drawer ${panelOpen ? "open" : ""}`} aria-hidden={!panelOpen}>
|
|
<div className="drawer-top">
|
|
<span>{!user ? "ACCOUNT" : panelType === "cart" ? "CART" : "PROFILE"}</span>
|
|
<button type="button" onClick={closePanel} aria-label="Close panel">
|
|
x
|
|
</button>
|
|
</div>
|
|
{!user ? <AuthPanel /> : panelType === "cart" ? <CartPanel /> : <ProfilePanel />}
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default ShopDrawer;
|