fix: react-doctor unused-file 27→0 (-27 more dead files, wholesale consolidate)
This commit is contained in:
@@ -1,288 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { createLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
}
|
||||
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
brandId: string;
|
||||
onSuccess?: (locationId: string) => void;
|
||||
};
|
||||
|
||||
export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [stateVal, setStateVal] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setName("");
|
||||
setAddress("");
|
||||
setCity("");
|
||||
setStateVal("");
|
||||
setZip("");
|
||||
setPhone("");
|
||||
setContactName("");
|
||||
setContactEmail("");
|
||||
setNotes("");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (loading) return;
|
||||
reset();
|
||||
onClose();
|
||||
}, [loading, reset, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError("Venue name is required.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createLocation(brandId, {
|
||||
name: name.trim(),
|
||||
address: address.trim() || null,
|
||||
city: city.trim() || null,
|
||||
state: stateVal.trim().toUpperCase() || null,
|
||||
zip: zip.trim() || null,
|
||||
phone: phone.trim() || null,
|
||||
contact_name: contactName.trim() || null,
|
||||
contact_email: contactEmail.trim() || null,
|
||||
notes: notes.trim() || null,
|
||||
active: true,
|
||||
});
|
||||
if (result.success) {
|
||||
onSuccess?.(result.id);
|
||||
reset();
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to create location");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title="Add Venue"
|
||||
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
|
||||
onClose={handleClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
||||
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-1-venue-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||
<input id="fld-1-venue-name" aria-label="Tractor Supply"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Tractor Supply"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-2-street-address" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||
<input id="fld-2-street-address" aria-label="13778 E I 25 Frontage Rd"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="13778 E I-25 Frontage Rd"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<label htmlFor="fld-3-city" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
<input id="fld-3-city" aria-label="Wellington"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Wellington"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-1.5">
|
||||
<label htmlFor="fld-4-state" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<input id="fld-4-state" aria-label="CO"
|
||||
type="text"
|
||||
value={stateVal}
|
||||
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label htmlFor="fld-5-zip" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<input id="fld-5-zip" aria-label="80549"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80549"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-6-phone" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||
<input id="fld-6-phone" aria-label="(970) 555 1234"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="(970) 555-1234"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-7-contact-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||
<input id="fld-7-contact-name" aria-label="Store Manager"
|
||||
type="text"
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
placeholder="Store manager"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-8-contact-email" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||
<input id="fld-8-contact-email" aria-label="Manager@example.com"
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
placeholder="manager@example.com"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-9-notes" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
|
||||
<textarea id="fld-9-notes" aria-label="Park On West Side. Use Side Entrance After 9am."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Park on west side. Use side entrance after 9am."
|
||||
rows={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: loading
|
||||
? "rgba(16, 185, 129, 0.4)"
|
||||
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
|
||||
boxShadow: loading
|
||||
? "none"
|
||||
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Creating…
|
||||
</span>
|
||||
) : (
|
||||
"Create Venue"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
brandId: string;
|
||||
duplicateFrom?: {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
} | null;
|
||||
onSuccess?: (stopId: string) => void;
|
||||
};
|
||||
|
||||
/* Pin icon for the modal header */
|
||||
const PinIcon = () => (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [city, setCity] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset form when modal opens (or duplicateFrom changes) — derived
|
||||
// during render per React docs: "Adjusting some state when a prop
|
||||
// changes". See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
const [prevOpenKey, setPrevOpenKey] = useState<string | null>(null);
|
||||
const openKey = isOpen ? `open:${duplicateFrom ? "dup" : "new"}` : "closed";
|
||||
if (openKey !== prevOpenKey) {
|
||||
setPrevOpenKey(openKey);
|
||||
setCity(duplicateFrom?.city ?? "");
|
||||
setStateField(duplicateFrom?.state ?? "");
|
||||
setLocation(duplicateFrom?.location ?? "");
|
||||
setDate(duplicateFrom?.date ?? "");
|
||||
setTime(duplicateFrom?.time ?? "");
|
||||
setAddress(duplicateFrom?.address ?? "");
|
||||
setZip(duplicateFrom?.zip ?? "");
|
||||
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
||||
setStatus("draft");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Focus the first field once the modal has actually opened. This is
|
||||
// a DOM side-effect, so it must run after the commit (i.e. in an
|
||||
// effect) — accessing `cityRef.current` during render is illegal.
|
||||
// The `prevOpenKey === "open:..."` guard keeps the focus call to
|
||||
// exactly the transition into the open state, not every re-render.
|
||||
useEffect(() => {
|
||||
if (prevOpenKey?.startsWith("open:")) {
|
||||
const id = requestAnimationFrame(() => cityRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
return undefined;
|
||||
}, [prevOpenKey]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createStop(brandId, {
|
||||
city: city.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || undefined,
|
||||
zip: zip || undefined,
|
||||
cutoff_time: cutoffTime || undefined,
|
||||
active: status === "active",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.(result.id);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to create stop");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
brandId,
|
||||
city,
|
||||
stateField,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
status,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]
|
||||
);
|
||||
|
||||
const isDuplicate = Boolean(duplicateFrom);
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
|
||||
const eyebrow = isDuplicate && duplicateFrom
|
||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||
: "New stop on the route";
|
||||
const submitLabel = loading
|
||||
? isDuplicate ? "Duplicating…" : "Creating…"
|
||||
: isDuplicate
|
||||
? "Duplicate Stop"
|
||||
: status === "active"
|
||||
? "Create & Publish"
|
||||
: "Save as Draft";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title={title}
|
||||
eyebrow={eyebrow}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-xl"
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 1 — Where: City + State */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Denver"
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="CO"
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Where at: Location (full) */}
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Whole Foods Market — Highlands"
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3 — When: Date + Time (with small clock icon) */}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Stop Date"
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input aria-label="Stop Time"
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input aria-label="123 Main St"
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input aria-label="80202"
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input aria-label="Stop Cutoff"
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</p>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => setStatus("draft")}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{status === "active" ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for easy integration
|
||||
export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicateFrom"]) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
const Modal = useCallback(() => (
|
||||
<AddStopModal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
brandId={brandId}
|
||||
duplicateFrom={duplicateFrom}
|
||||
/>
|
||||
), [isOpen, close, brandId, duplicateFrom]);
|
||||
|
||||
return { open, close, Modal };
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
|
||||
// Types
|
||||
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
|
||||
interface AISettings {
|
||||
provider: AIProvider;
|
||||
apiKey: string;
|
||||
orgId: string;
|
||||
model: string;
|
||||
customEndpoint: string;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Provider configuration
|
||||
const PROVIDERS = [
|
||||
{
|
||||
id: "openai" as AIProvider,
|
||||
name: "OpenAI",
|
||||
color: "bg-emerald-500",
|
||||
placeholder: "sk-...",
|
||||
website: "openai.com",
|
||||
description: "GPT-4o, GPT-4 Turbo. Industry standard.",
|
||||
models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
|
||||
hasOrgId: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic" as AIProvider,
|
||||
name: "Anthropic",
|
||||
color: "bg-amber-500",
|
||||
placeholder: "sk-ant-...",
|
||||
website: "anthropic.com",
|
||||
description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.",
|
||||
models: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
|
||||
hasOrgId: false,
|
||||
},
|
||||
{
|
||||
id: "google" as AIProvider,
|
||||
name: "Google",
|
||||
color: "bg-blue-500",
|
||||
placeholder: "AIza...",
|
||||
website: "ai.google",
|
||||
description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.",
|
||||
models: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
|
||||
hasOrgId: false,
|
||||
},
|
||||
{
|
||||
id: "xai" as AIProvider,
|
||||
name: "xAI",
|
||||
color: "bg-orange-500",
|
||||
placeholder: "xai-...",
|
||||
website: "x.ai",
|
||||
description: "Grok-2, Grok-1.5. Real-time data, humor.",
|
||||
models: ["grok-2", "grok-2-mini", "grok-1.5"],
|
||||
hasOrgId: false,
|
||||
},
|
||||
{
|
||||
id: "custom" as AIProvider,
|
||||
name: "Custom",
|
||||
color: "bg-stone-500",
|
||||
placeholder: "sk-...",
|
||||
website: "",
|
||||
description: "Any OpenAI-compatible API endpoint.",
|
||||
models: ["gpt-4o-mini"],
|
||||
hasOrgId: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Estimated costs per 1M tokens (for display only)
|
||||
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
|
||||
"gpt-4o": { input: 2.50, output: 10.00 },
|
||||
"gpt-4o-mini": { input: 0.15, output: 0.60 },
|
||||
"gpt-4-turbo": { input: 10.00, output: 30.00 },
|
||||
"gpt-3.5-turbo": { input: 0.50, output: 1.50 },
|
||||
"claude-3-5-sonnet-20241002": { input: 3.00, output: 15.00 },
|
||||
"claude-3-5-sonnet-20250611": { input: 3.00, output: 15.00 },
|
||||
"claude-3-opus-20240229": { input: 15.00, output: 75.00 },
|
||||
"claude-3-haiku-20240307": { input: 0.80, output: 4.00 },
|
||||
"gemini-2.0-flash": { input: 0.10, output: 0.40 },
|
||||
"gemini-1.5-pro": { input: 1.25, output: 5.00 },
|
||||
"gemini-1.5-flash": { input: 0.075, output: 0.30 },
|
||||
};
|
||||
|
||||
// Icons
|
||||
const SparkleIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RobotIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="10" rx="2"/>
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<path d="M12 7v4"/>
|
||||
<line x1="8" y1="16" x2="8" y2="16"/>
|
||||
<line x1="16" y1="16" x2="16" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BrainIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-2.5 2.5M4.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M14.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M9.5 16a2.5 2.5 0 015 0"/>
|
||||
<path d="M12 4.5v15"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TornadoIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 4H3M21 4v16M3 8l3 3 3-3 3 3 3-3 3 3M3 16l3 3 3-3 3 3 3-3 3 3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WrenchIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeOffIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlertIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BotIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 8V4H8"/>
|
||||
<rect x="4" y="8" width="16" height="12" rx="2"/>
|
||||
<path d="M2 14h2M20 14h2M15 13v2M9 13v2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PROVIDER_ICONS: Record<AIProvider, React.ReactNode> = {
|
||||
openai: <RobotIcon className="h-5 w-5" />,
|
||||
anthropic: <BrainIcon className="h-5 w-5" />,
|
||||
google: <CircleIcon className="h-5 w-5" />,
|
||||
xai: <TornadoIcon className="h-5 w-5" />,
|
||||
custom: <WrenchIcon className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function AdvancedAIPanel({ brandId }: Props) {
|
||||
// Settings state
|
||||
const [settings, setSettings] = useState<AISettings>({
|
||||
provider: "openai",
|
||||
apiKey: "",
|
||||
orgId: "",
|
||||
model: "gpt-4o-mini",
|
||||
customEndpoint: "",
|
||||
});
|
||||
|
||||
// UI state
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load existing settings via the AI-provider server action. Server actions
|
||||
// are normal async functions on the client (no `fetch` to flag) and the
|
||||
// action's auth gate ensures only the brand admin can read these settings.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await getAIProviderSettings(brandId);
|
||||
if (cancelled) return;
|
||||
setSettings({
|
||||
provider: (data.provider as AIProvider) ?? "openai",
|
||||
apiKey: data.apiKey ?? "",
|
||||
orgId: data.orgId ?? "",
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.customEndpoint ?? "",
|
||||
});
|
||||
} catch (err) {
|
||||
if (!cancelled) console.error("Failed to load AI settings:", err);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [brandId]);
|
||||
|
||||
// Track changes
|
||||
const handleChange = useCallback((field: keyof AISettings, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
// Select provider
|
||||
const handleSelectProvider = useCallback((provider: AIProvider) => {
|
||||
const providerConfig = PROVIDERS.find((p) => p.id === provider);
|
||||
const defaultModel = providerConfig?.models[0] ?? "gpt-4o-mini";
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: defaultModel,
|
||||
}));
|
||||
setHasChanges(true);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
// Save settings
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/ai-provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
brandId,
|
||||
...settings,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Failed to save");
|
||||
setResult({ ok: true, message: "AI provider settings saved successfully" });
|
||||
setHasChanges(false);
|
||||
} catch (err) {
|
||||
setResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [brandId, settings]);
|
||||
|
||||
// Test connection
|
||||
const handleTest = useCallback(async () => {
|
||||
if (!settings.apiKey?.trim()) {
|
||||
setResult({ ok: false, message: "API key is required to test connection" });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/ai-provider/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Connection failed");
|
||||
setResult({ ok: true, message: data.message ?? "Connection successful" });
|
||||
} catch (err) {
|
||||
setResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [brandId, settings.apiKey]);
|
||||
|
||||
// Get current provider config
|
||||
const currentProvider = PROVIDERS.find((p) => p.id === settings.provider) ?? PROVIDERS[0];
|
||||
const modelCosts = MODEL_COSTS[settings.model];
|
||||
|
||||
// Loading skeleton
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 animate-pulse">
|
||||
<div className="h-4 bg-amber-100 rounded w-1/3 mb-2" />
|
||||
<div className="h-3 bg-amber-100 rounded w-2/3" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6 animate-pulse">
|
||||
<div className="h-6 bg-stone-200 rounded w-1/4 mb-4" />
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-20 bg-stone-100 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Security Warning Banner */}
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
|
||||
<ShieldIcon className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
|
||||
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
|
||||
<li>• <strong>Never share keys</strong> — Don't paste in Slack, email, or screenshots.</li>
|
||||
<li>• <strong>Usage costs add up fast</strong> — AI APIs charge per token. A busy month can hit $50-$200+.</li>
|
||||
<li>• <strong>Set budgets now</strong> — Use provider dashboard limits. Start with $10-$25/mo cap.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500">
|
||||
<SparkleIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Choose your AI model provider for all AI tools</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
|
||||
{PROVIDERS.map((provider) => (
|
||||
<button type="button"
|
||||
key={provider.id}
|
||||
onClick={() => handleSelectProvider(provider.id)}
|
||||
className={`rounded-xl p-3 text-center transition-all border-2 ${
|
||||
settings.provider === provider.id
|
||||
? "border-violet-500 bg-violet-50"
|
||||
: "border-[var(--admin-border)] hover:border-violet-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 mx-auto items-center justify-center rounded-xl mb-2 ${provider.color}`}>
|
||||
<span className="text-white">{PROVIDER_ICONS[provider.id]}</span>
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-[var(--admin-text-primary)]">{provider.name}</p>
|
||||
{provider.website && (
|
||||
<p className="text-[10px] text-[var(--admin-text-muted)] mt-0.5">{provider.website}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials Card */}
|
||||
{settings.provider !== "custom" ? (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Credentials</h3>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">{currentProvider.website}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input id="fld-1-api-key" aria-label="Input"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||
placeholder={currentProvider.placeholder}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-stone-400">
|
||||
Get your API key from {currentProvider.website}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Organization ID (OpenAI only) */}
|
||||
{currentProvider.hasOrgId && (
|
||||
<div>
|
||||
<label htmlFor="fld-2-organization-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
Organization ID <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<input id="fld-2-organization-id" aria-label="Org ..."
|
||||
type="text"
|
||||
value={settings.orgId}
|
||||
onChange={(e) => handleChange("orgId", e.target.value)}
|
||||
placeholder="org-..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Custom Endpoint Card */
|
||||
<div className="rounded-xl border border-violet-200 bg-violet-50 overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-violet-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<WrenchIcon className="h-5 w-5 text-violet-600" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-violet-800">Custom API Endpoint</p>
|
||||
<p className="text-xs text-violet-600 mt-0.5">Connect any OpenAI-compatible API</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fld-3-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input id="fld-3-api-key" aria-label="Sk ..."
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowApiKey(!showApiKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
|
||||
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-4-base-url" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
Base URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-4-base-url" aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
|
||||
type="text"
|
||||
value={settings.customEndpoint}
|
||||
onChange={(e) => handleChange("customEndpoint", e.target.value)}
|
||||
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-stone-500">
|
||||
Examples: OpenAI proxy, Ollama (localhost:11434), LM Studio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Model</h3>
|
||||
{modelCosts && (
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)]">Est. cost per 1M tokens:</span>
|
||||
<div className="flex gap-2 text-[10px]">
|
||||
<span className="text-stone-500">In: ${modelCosts.input}</span>
|
||||
<span className="text-stone-500">Out: ${modelCosts.output}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentProvider.models.map((model) => (
|
||||
<button type="button"
|
||||
key={model}
|
||||
onClick={() => handleChange("model", model)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
settings.model === model
|
||||
? "bg-violet-600 text-white"
|
||||
: "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Features Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Features</h3>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ name: "Campaign Writer", desc: "Generate email content", icon: <BotIcon className="h-4 w-4" /> },
|
||||
{ name: "Product Writer", desc: "Product descriptions", icon: <SparkleIcon className="h-4 w-4" /> },
|
||||
{ name: "Report Explainer", desc: "AI summaries", icon: <BotIcon className="h-4 w-4" /> },
|
||||
{ name: "Pricing Advisor", desc: "Smart pricing", icon: <SparkleIcon className="h-4 w-4" /> },
|
||||
].map((feature) => (
|
||||
<div key={feature.name} className="bg-stone-50 rounded-lg p-3 flex items-center gap-2">
|
||||
<div className="text-emerald-600">{feature.icon}</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{feature.name}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{feature.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result Message */}
|
||||
{result && (
|
||||
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
|
||||
result.ok
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
{result.ok ? <CheckIcon className="h-4 w-4 flex-shrink-0" /> : <AlertIcon className="h-4 w-4 flex-shrink-0" />}
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || saving}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || testing}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : hasChanges ? "Save Provider Settings" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
|
||||
|
||||
// Icons
|
||||
const MailIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MessageIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeOffIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlertIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brands: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
export default function AdvancedIntegrations({ brandId, brands }: Props) {
|
||||
// The selected brand is always the current `brandId` prop — there is no
|
||||
// in-component brand picker, so derive it directly to avoid a stale copy
|
||||
// when the parent re-renders with a different brand.
|
||||
const selectedBrandId = brandId;
|
||||
const [credentials, setCredentials] = useState<{
|
||||
resend: { api_key: string; from_email: string; from_name: string };
|
||||
twilio: { account_sid: string; auth_token: string; phone_number: string };
|
||||
}>({
|
||||
resend: { api_key: "", from_email: "", from_name: "" },
|
||||
twilio: { account_sid: "", auth_token: "", phone_number: "" },
|
||||
});
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
|
||||
const [saveStatus, setSaveStatus] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCredentials() {
|
||||
const [resendCreds, twilioCreds] = await Promise.all([
|
||||
getResendCredentials(selectedBrandId),
|
||||
getTwilioCredentials(selectedBrandId),
|
||||
]);
|
||||
|
||||
setCredentials({
|
||||
resend: {
|
||||
api_key: resendCreds.api_key ?? "",
|
||||
from_email: resendCreds.from_email ?? "",
|
||||
from_name: resendCreds.from_name ?? "",
|
||||
},
|
||||
twilio: {
|
||||
account_sid: twilioCreds.account_sid ?? "",
|
||||
auth_token: twilioCreds.auth_token ?? "",
|
||||
phone_number: twilioCreds.phone_number ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
fetchCredentials();
|
||||
}, [selectedBrandId]);
|
||||
|
||||
function toggleSecret(key: string) {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
async function handleTest(service: "resend" | "twilio") {
|
||||
// Clear previous result for this service
|
||||
const newResults = { ...testResults };
|
||||
delete newResults[service];
|
||||
setTestResults(newResults);
|
||||
|
||||
if (service === "resend") {
|
||||
if (!credentials.resend.api_key?.trim()) {
|
||||
setTestResults((prev) => ({ ...prev, resend: { ok: false, message: "API key is required" } }));
|
||||
return;
|
||||
}
|
||||
const result = await testResendConnection(credentials.resend.api_key);
|
||||
setTestResults((prev) => ({ ...prev, resend: result }));
|
||||
} else {
|
||||
if (!credentials.twilio.account_sid?.trim() || !credentials.twilio.auth_token?.trim()) {
|
||||
setTestResults((prev) => ({ ...prev, twilio: { ok: false, message: "Account SID and Auth Token are required" } }));
|
||||
return;
|
||||
}
|
||||
const result = await testTwilioConnection(credentials.twilio.account_sid, credentials.twilio.auth_token);
|
||||
setTestResults((prev) => ({ ...prev, twilio: result }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(service: "resend" | "twilio") {
|
||||
setLoading(true);
|
||||
setSaveStatus(null);
|
||||
// Clear test result for this service
|
||||
const clearedResults = { ...testResults };
|
||||
delete clearedResults[service];
|
||||
setTestResults(clearedResults);
|
||||
|
||||
try {
|
||||
if (service === "resend") {
|
||||
const result = await saveResendCredentials(selectedBrandId, {
|
||||
api_key: credentials.resend.api_key?.trim() || null,
|
||||
from_email: credentials.resend.from_email?.trim() || null,
|
||||
from_name: credentials.resend.from_name?.trim() || null,
|
||||
});
|
||||
if (result.success) {
|
||||
setSaveStatus({ type: "success", message: "Resend credentials saved" });
|
||||
setTestResults((prev) => ({ ...prev, resend: { ok: true, message: "Saved successfully" } }));
|
||||
} else {
|
||||
setSaveStatus({ type: "error", message: result.error ?? "Failed to save" });
|
||||
}
|
||||
} else {
|
||||
const result = await saveTwilioCredentials(selectedBrandId, {
|
||||
account_sid: credentials.twilio.account_sid?.trim() || null,
|
||||
auth_token: credentials.twilio.auth_token?.trim() || null,
|
||||
phone_number: credentials.twilio.phone_number?.trim() || null,
|
||||
});
|
||||
if (result.success) {
|
||||
setSaveStatus({ type: "success", message: "Twilio credentials saved" });
|
||||
setTestResults((prev) => ({ ...prev, twilio: { ok: true, message: "Saved successfully" } }));
|
||||
} else {
|
||||
setSaveStatus({ type: "error", message: result.error ?? "Failed to save" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveStatus({ type: "error", message: err instanceof Error ? err.message : "Failed to save" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Security Warning Banner */}
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
|
||||
<ShieldIcon className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
|
||||
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
|
||||
<li>• <strong>Never share keys</strong> — Don't paste in Slack, email, or commit to GitHub.</li>
|
||||
<li>• <strong>SMS costs add up fast</strong> — Twilio charges $0.008-$0.05 per message.</li>
|
||||
<li>• <strong>Set spending caps</strong> — Use provider budget controls to avoid surprise bills.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resend Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500">
|
||||
<MailIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Resend</h3>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Email delivery for Harvest Reach campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<div className="relative">
|
||||
<input id="fld-1-api-key" aria-label="Re ..."
|
||||
type={showSecrets.resendKey ? "text" : "password"}
|
||||
value={credentials.resend.api_key}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
...p,
|
||||
resend: { ...p.resend, api_key: e.target.value },
|
||||
}))}
|
||||
placeholder="re_..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret("resendKey")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
{showSecrets.resendKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* From Email */}
|
||||
<div>
|
||||
<label htmlFor="fld-1-from-email" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Email</label>
|
||||
<input id="fld-1-from-email" aria-label="Orders@yourbrand.com"
|
||||
type="email"
|
||||
value={credentials.resend.from_email}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
...p,
|
||||
resend: { ...p.resend, from_email: e.target.value },
|
||||
}))}
|
||||
placeholder="orders@yourbrand.com"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{/* From Name */}
|
||||
<div>
|
||||
<label htmlFor="fld-2-from-name" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Name</label>
|
||||
<input id="fld-2-from-name" aria-label="Your Brand Name"
|
||||
type="text"
|
||||
value={credentials.resend.from_name}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
...p,
|
||||
resend: { ...p.resend, from_name: e.target.value },
|
||||
}))}
|
||||
placeholder="Your Brand Name"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Status & Actions */}
|
||||
{testResults.resend && (
|
||||
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResults.resend.ok
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
{testResults.resend.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
|
||||
{testResults.resend.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={() => handleTest("resend")}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleSave("resend")}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Resend"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Twilio Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500">
|
||||
<MessageIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Twilio</h3>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">SMS campaigns and alerts via Harvest Reach</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Account SID */}
|
||||
<div>
|
||||
<label htmlFor="fld-3-account-sid" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account SID</label>
|
||||
<input id="fld-3-account-sid" aria-label="AC..."
|
||||
type="text"
|
||||
value={credentials.twilio.account_sid}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
...p,
|
||||
twilio: { ...p.twilio, account_sid: e.target.value },
|
||||
}))}
|
||||
placeholder="AC..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Auth Token */}
|
||||
<div>
|
||||
<label htmlFor="fld-5-auth-token" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
|
||||
<div className="relative">
|
||||
<input id="fld-5-auth-token" aria-label="Your Twilio Auth Token"
|
||||
type={showSecrets.twilioToken ? "text" : "password"}
|
||||
value={credentials.twilio.auth_token}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
...p,
|
||||
twilio: { ...p.twilio, auth_token: e.target.value },
|
||||
}))}
|
||||
placeholder="Your Twilio auth token"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm pr-10 text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret("twilioToken")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
{showSecrets.twilioToken ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Phone Number */}
|
||||
<div>
|
||||
<label htmlFor="fld-4-phone-number" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Phone Number</label>
|
||||
<input id="fld-4-phone-number" aria-label="+1234567890"
|
||||
type="tel"
|
||||
value={credentials.twilio.phone_number}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
...p,
|
||||
twilio: { ...p.twilio, phone_number: e.target.value },
|
||||
}))}
|
||||
placeholder="+1234567890"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Status & Actions */}
|
||||
{testResults.twilio && (
|
||||
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResults.twilio.ok
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
{testResults.twilio.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
|
||||
{testResults.twilio.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={() => handleTest("twilio")}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleSave("twilio")}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Twilio"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Status */}
|
||||
{saveStatus && (
|
||||
<div className={`rounded-xl px-4 py-3 text-sm font-medium ${
|
||||
saveStatus.type === "success"
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
{saveStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
createStripeConnectLink,
|
||||
refreshStripeConnectLink,
|
||||
disconnectStripeConnect,
|
||||
createStripeDashboardLink,
|
||||
getStripeConnectStatus,
|
||||
} from "@/actions/stripe-connect";
|
||||
|
||||
interface AdvancedPaymentsProps {
|
||||
brandId: string;
|
||||
initialStatus: {
|
||||
is_connected: boolean;
|
||||
account_id?: string;
|
||||
charges_enabled?: boolean;
|
||||
payouts_enabled?: boolean;
|
||||
details_submitted?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// SVG Icons
|
||||
const CreditCardIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<path d="M2 10h20"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExternalLinkIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlertTriangleIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckCircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XCircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M15 9l-6 6M9 9l6 6"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LoaderIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeOpacity="0.25"/>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 019-9 9.75 9.75 0 016.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 01-9 9 9.75 9.75 0 01-6.74-2.74L3 16"/>
|
||||
<path d="M8 16H3v5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UnlinkIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18.84 12.25l1.72-1.71h-.02a5.004 5.004 0 00-.12-7.07 5.006 5.006 0 00-6.95 0l-1.72 1.71"/>
|
||||
<path d="M5.17 11.75l-1.71 1.71a5.004 5.004 0 00.12 7.07 5.006 5.006 0 006.95 0l1.71-1.71"/>
|
||||
<line x1="8" y1="2" x2="8" y2="5"/>
|
||||
<line x1="2" y1="8" x2="5" y2="8"/>
|
||||
<line x1="16" y1="19" x2="16" y2="22"/>
|
||||
<line x1="19" y1="16" x2="22" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const InfoIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const StoreIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DollarIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPaymentsProps) {
|
||||
const [status, setStatus] = useState(initialStatus ?? { is_connected: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<"connect" | "refresh" | "dashboard" | "disconnect" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
async function refreshStatus() {
|
||||
const result = await getStripeConnectStatus(brandId);
|
||||
if (!result.error) {
|
||||
setStatus(result);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
setLoading(true);
|
||||
setAction("connect");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const result = await createStripeConnectLink(brandId);
|
||||
|
||||
if (!result.success || !result.url) {
|
||||
setError(result.error ?? "Failed to create Stripe account");
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to Stripe onboarding
|
||||
window.location.href = result.url;
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
setLoading(true);
|
||||
setAction("refresh");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const result = await refreshStripeConnectLink(brandId);
|
||||
|
||||
if (!result.success || !result.url) {
|
||||
setError(result.error ?? "Failed to refresh onboarding");
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to Stripe to continue onboarding
|
||||
window.location.href = result.url;
|
||||
}
|
||||
|
||||
async function handleOpenDashboard() {
|
||||
setLoading(true);
|
||||
setAction("dashboard");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const result = await createStripeDashboardLink(brandId);
|
||||
|
||||
if (!result.success || !result.url) {
|
||||
setError(result.error ?? "Failed to open Stripe dashboard");
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open Stripe dashboard in new tab
|
||||
window.open(result.url, "_blank");
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!confirm("Disconnect Stripe? Your brand store will no longer be able to accept payments until reconnected.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setAction("disconnect");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const result = await disconnectStripeConnect(brandId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to disconnect");
|
||||
} else {
|
||||
setStatus({ is_connected: false });
|
||||
setSuccess("Stripe disconnected successfully");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
}
|
||||
|
||||
const isFullyOnboarded = status.is_connected && status.charges_enabled && status.payouts_enabled && status.details_submitted;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner - Two Stripe Systems */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500 flex-shrink-0">
|
||||
<StoreIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)]">Brand Store Payments</h4>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
This connects your brand's storefront to <strong>your own Stripe account</strong> so you can accept payments directly from your customers.
|
||||
This is separate from the Route Commerce SaaS subscription billing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Warning Banner */}
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500 flex-shrink-0">
|
||||
<ShieldIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-800">How Stripe Connect Works</h4>
|
||||
<ul className="mt-2 text-xs text-amber-700 space-y-1.5">
|
||||
<li>• <strong>You sign in to YOUR Stripe account</strong> — No API keys to manage or share.</li>
|
||||
<li>• <strong>Express accounts are pre-configured</strong> — Card payments and transfers enabled.</li>
|
||||
<li>• <strong>Stripe handles compliance</strong> — Identity verification and tax forms handled by Stripe.</li>
|
||||
<li>• <strong>Funds go directly to you</strong> — Payments deposit to your connected Stripe account.</li>
|
||||
<li>• <strong>2-7 day payout schedule</strong> — Standard Stripe payout timing to your bank.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Connect Status Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
{/* Card Header */}
|
||||
<div className={`px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] ${
|
||||
isFullyOnboarded
|
||||
? "bg-emerald-50/50"
|
||||
: status.is_connected
|
||||
? "bg-amber-50/50"
|
||||
: "bg-stone-50/50"
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||
isFullyOnboarded
|
||||
? "bg-emerald-500"
|
||||
: status.is_connected
|
||||
? "bg-amber-500"
|
||||
: "bg-stone-500"
|
||||
}`}>
|
||||
<CreditCardIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Stripe Connect</h2>
|
||||
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
|
||||
Accept payments on your brand storefront
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
{status.is_connected ? (
|
||||
<div className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-semibold border ${
|
||||
isFullyOnboarded
|
||||
? "bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||
: "bg-amber-100 text-amber-700 border-amber-200"
|
||||
}`}>
|
||||
{isFullyOnboarded ? (
|
||||
<CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<AlertTriangleIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isFullyOnboarded ? "Active" : "Incomplete"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-stone-100 px-3 py-1.5 text-xs font-semibold text-[var(--admin-text-secondary)] border border-stone-200">
|
||||
<XCircleIcon className="h-3.5 w-3.5" />
|
||||
Not Connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Account Info */}
|
||||
{status.account_id && (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)]">
|
||||
<span>Account:</span>
|
||||
<code className="rounded border border-[var(--admin-border)] bg-stone-50 px-1.5 py-0.5 font-mono text-[var(--admin-text-secondary)]">
|
||||
{status.account_id}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding Status */}
|
||||
{status.is_connected && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<StatusIndicator
|
||||
label="Charges"
|
||||
enabled={status.charges_enabled}
|
||||
description="Can accept card payments"
|
||||
/>
|
||||
<StatusIndicator
|
||||
label="Payouts"
|
||||
enabled={status.payouts_enabled}
|
||||
description="Can receive transfers"
|
||||
/>
|
||||
<StatusIndicator
|
||||
label="Details"
|
||||
enabled={status.details_submitted}
|
||||
description="Business verified"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mt-4 rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700 flex items-center gap-2">
|
||||
<CheckCircleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{!status.is_connected ? (
|
||||
<button type="button"
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{loading && action === "connect" ? (
|
||||
<LoaderIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CreditCardIcon className="h-4 w-4" />
|
||||
)}
|
||||
Connect with Stripe
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{!isFullyOnboarded && (
|
||||
<button type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-500 px-5 py-2.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{loading && action === "refresh" ? (
|
||||
<LoaderIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
)}
|
||||
Complete Setup
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={handleOpenDashboard}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
{loading && action === "dashboard" ? (
|
||||
<LoaderIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
)}
|
||||
Stripe Dashboard
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleDisconnect}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{loading && action === "disconnect" ? (
|
||||
<LoaderIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<UnlinkIcon className="h-4 w-4" />
|
||||
)}
|
||||
Disconnect
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works Section */}
|
||||
{!status.is_connected && (
|
||||
<div className="border-t border-[var(--admin-border)] px-4 py-4 sm:px-6 bg-stone-50/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<InfoIcon className="h-4 w-4 text-[var(--admin-text-muted)] mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[var(--admin-text-secondary)] mb-2">Setup steps:</p>
|
||||
<ol className="text-xs text-[var(--admin-text-muted)] space-y-1.5 list-decimal list-inside">
|
||||
<li>Click "Connect with Stripe" to start</li>
|
||||
<li>Sign in to your Stripe account or create one</li>
|
||||
<li>Complete business verification in the Stripe form</li>
|
||||
<li>Once approved, start accepting payments on your store</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Banner */}
|
||||
{isFullyOnboarded && (
|
||||
<div className="border-t border-emerald-200 px-4 py-4 sm:px-6 bg-emerald-50/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<DollarIcon className="h-4 w-4 text-emerald-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-emerald-700 mb-1">Ready to Accept Payments</p>
|
||||
<p className="text-xs text-emerald-600">
|
||||
Your Stripe account is fully set up. Payments from your brand storefront will be deposited directly to your Stripe account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({
|
||||
label,
|
||||
enabled,
|
||||
description,
|
||||
}: {
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2.5 rounded-lg px-3 py-2 border ${
|
||||
enabled
|
||||
? "bg-emerald-50 border-emerald-200"
|
||||
: "bg-amber-50 border-amber-200"
|
||||
}`}>
|
||||
{enabled ? (
|
||||
<CheckCircleIcon className="h-4 w-4 text-emerald-600 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangleIcon className="h-4 w-4 text-amber-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`text-xs font-semibold ${enabled ? "text-emerald-700" : "text-amber-700"}`}>
|
||||
{label} {enabled ? "Enabled" : "Pending"}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--admin-text-muted)]">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// Icons
|
||||
const TruckIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlertIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeOffIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdvancedShipping({ brandId }: { brandId: string }) {
|
||||
const [carrier, setCarrier] = useState("fedex");
|
||||
const [accountNumber, setAccountNumber] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
if (!accountNumber.trim()) {
|
||||
setTestResult({ ok: false, message: "Account number is required" });
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
setTestResult({ ok: true, message: `${carrier.toUpperCase()} connection successful` });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
setSaving(false);
|
||||
setTestResult({ ok: true, message: "Shipping settings saved" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Security Warning */}
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
|
||||
<ShieldIcon className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
|
||||
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
|
||||
<li>• <strong>Shipping fraud is real</strong> — Stolen carrier credentials enable fake label purchases on your account.</li>
|
||||
<li>• <strong>Carrier API costs</strong> — FedEx/UPS APIs charge per rate quote ($0.01-$0.05) and per label ($3-$10).</li>
|
||||
<li>• <strong>Set account limits</strong> — Most carriers let you set monthly spending caps. Use them!</li>
|
||||
<li>• <strong>Test in sandbox</strong> — Use test credentials before connecting production shipping accounts.</li>
|
||||
<li>• <strong>Audit regularly</strong> — Check carrier invoices monthly for unauthorized activity.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Carriers Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-600">
|
||||
<TruckIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Shipping Carriers</h3>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Configure shipping provider credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Carrier Selection */}
|
||||
<div>
|
||||
<label htmlFor="fld-1-carrier" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Carrier</label>
|
||||
<select id="fld-1-carrier" aria-label="Select"
|
||||
value={carrier}
|
||||
onChange={(e) => setCarrier(e.target.value)}
|
||||
className="w-full sm:w-auto rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="fedex">FedEx</option>
|
||||
<option value="ups">UPS</option>
|
||||
<option value="usps">USPS</option>
|
||||
<option value="dhl">DHL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Account Number */}
|
||||
<div>
|
||||
<label htmlFor="fld-2-account-number" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account Number</label>
|
||||
<input id="fld-2-account-number" aria-label="Enter Account Number"
|
||||
type="text"
|
||||
value={accountNumber}
|
||||
onChange={(e) => setAccountNumber(e.target.value)}
|
||||
placeholder="Enter account number"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key / Password */}
|
||||
<div>
|
||||
<label htmlFor="fld-3-api-key-password" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
|
||||
<div className="relative">
|
||||
<input id="fld-3-api-key-password" aria-label="Enter API Key Or Password"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter API key or password"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 pr-10 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={handleTest}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
aria-label="Test Connection">
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Options Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Shipping Options</h3>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ id: "live_rates", label: "Live Rate Quotes", desc: "Show real-time shipping rates at checkout" },
|
||||
{ id: "label_printing", label: "Label Printing", desc: "Generate shipping labels automatically" },
|
||||
{ id: "tracking", label: "Tracking Updates", desc: "Send tracking notifications to customers" },
|
||||
{ id: "insurance", label: "Shipping Insurance", desc: "Protect high-value shipments" },
|
||||
].map((option) => (
|
||||
<div key={option.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
|
||||
</div>
|
||||
<button type="button" className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
|
||||
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// Icons
|
||||
const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GridIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 3h6v6H3V3zm0 12h6v6H3v-6zm12-12h6v6h-6V3zm0 12h6v6h-6v-6z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AlertIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [appId, setAppId] = useState("");
|
||||
const [accessToken, setAccessToken] = useState("");
|
||||
const [locationId, setLocationId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
if (!appId.trim() || !accessToken.trim()) {
|
||||
setTestResult({ ok: false, message: "App ID and Access Token are required" });
|
||||
return;
|
||||
}
|
||||
// Simulate test
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
setTestResult({ ok: true, message: "Square connection successful" });
|
||||
setConnected(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
setSaving(false);
|
||||
setTestResult({ ok: true, message: "Square sync settings saved" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Security Warning */}
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
|
||||
<ShieldIcon className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
|
||||
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
|
||||
<li>• <strong>Square access = real money</strong> — API can process transactions, refunds, and access customer data.</li>
|
||||
<li>• <strong>Monitor API calls</strong> — Square charges per API call. Heavy integrations can run up fees.</li>
|
||||
<li>• <strong>Token expiry</strong> — Square access tokens expire. Production apps need automatic refresh logic.</li>
|
||||
<li>• <strong>Use sandbox first</strong> — Test with Square sandbox before going live to avoid real charges.</li>
|
||||
<li>• <strong>Secure storage</strong> — Never expose credentials in client code or public repositories.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Square Sync Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black">
|
||||
<GridIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Square Sync</h3>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Sync inventory with Square POS</p>
|
||||
</div>
|
||||
{connected && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-full">Connected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-1-application-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Application ID</label>
|
||||
<input id="fld-1-application-id" aria-label="Sq0idp ..."
|
||||
type="password"
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
placeholder="sq0idp-..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-2-access-token" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Access Token</label>
|
||||
<input id="fld-2-access-token" aria-label="EAAAl..."
|
||||
type="password"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
placeholder="EAAAl..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-3-location-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Location ID</label>
|
||||
<input id="fld-3-location-id" aria-label="L..."
|
||||
type="text"
|
||||
value={locationId}
|
||||
onChange={(e) => setLocationId(e.target.value)}
|
||||
placeholder="L..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={handleTest}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||
aria-label="Test Connection">
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Options */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Sync Options</h3>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ id: "inventory", label: "Inventory Sync", desc: "Sync product quantities from Square" },
|
||||
{ id: "products", label: "Product Sync", desc: "Import/export products from Square catalog" },
|
||||
{ id: "prices", label: "Price Updates", desc: "Keep prices synchronized" },
|
||||
].map((option) => (
|
||||
<div key={option.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
|
||||
</div>
|
||||
<button type="button" className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
|
||||
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { updateLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
}
|
||||
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
export type LocationForEdit = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
phone: string | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
notes: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
location: LocationForEdit | null;
|
||||
brandId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function EditLocationModal({ isOpen, onClose, location, brandId, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [stateVal, setStateVal] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Reset form when location or isOpen changes — derived during render
|
||||
// per React docs: "Adjusting some state when a prop changes".
|
||||
const [prevSyncKey, setPrevSyncKey] = useState<string | null>(null);
|
||||
const syncKey = `${isOpen ? "open" : "closed"}:${location?.id ?? "none"}`;
|
||||
if (syncKey !== prevSyncKey) {
|
||||
setPrevSyncKey(syncKey);
|
||||
if (location && isOpen) {
|
||||
setName(location.name ?? "");
|
||||
setAddress(location.address ?? "");
|
||||
setCity(location.city ?? "");
|
||||
setStateVal(location.state ?? "");
|
||||
setZip(location.zip ?? "");
|
||||
setPhone(location.phone ?? "");
|
||||
setContactName(location.contact_name ?? "");
|
||||
setContactEmail(location.contact_email ?? "");
|
||||
setNotes(location.notes ?? "");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (loading) return;
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [loading, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!location) return;
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError("Venue name is required.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await updateLocation(location.id, brandId, {
|
||||
name: name.trim(),
|
||||
address: address.trim() || null,
|
||||
city: city.trim() || null,
|
||||
state: stateVal.trim().toUpperCase() || null,
|
||||
zip: zip.trim() || null,
|
||||
phone: phone.trim() || null,
|
||||
contact_name: contactName.trim() || null,
|
||||
contact_email: contactEmail.trim() || null,
|
||||
notes: notes.trim() || null,
|
||||
active: location.active,
|
||||
});
|
||||
if (result.success) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to update location");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
|
||||
);
|
||||
|
||||
if (!isOpen || !location) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title="Edit Venue"
|
||||
subtitle="Changes apply to every stop currently linked to this venue."
|
||||
onClose={handleClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
||||
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-1-venue-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||
<input id="fld-1-venue-name" aria-label="Text"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-2-street-address" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||
<input id="fld-2-street-address" aria-label="Text"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<label htmlFor="fld-3-city" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
<input id="fld-3-city" aria-label="Text"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-1.5">
|
||||
<label htmlFor="fld-4-state" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<input id="fld-4-state" aria-label="Text"
|
||||
type="text"
|
||||
value={stateVal}
|
||||
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
|
||||
maxLength={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label htmlFor="fld-5-zip" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<input id="fld-5-zip" aria-label="Text"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-6-phone" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||
<input id="fld-6-phone" aria-label="Tel"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-7-contact-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||
<input id="fld-7-contact-name" aria-label="Text"
|
||||
type="text"
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-8-contact-email" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||
<input id="fld-8-contact-email" aria-label="Email"
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="fld-9-notes" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
|
||||
<textarea id="fld-9-notes" aria-label="Textarea"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all resize-none"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: loading
|
||||
? "rgba(16, 185, 129, 0.4)"
|
||||
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
|
||||
boxShadow: loading
|
||||
? "none"
|
||||
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Saving…
|
||||
</span>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSyncLog, syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function SquareSyncWidget({ brandId }: Props) {
|
||||
const [settings, setSettings] = useState<{
|
||||
provider: string | null;
|
||||
square_access_token: string | null;
|
||||
square_sync_enabled: boolean;
|
||||
square_inventory_mode: string;
|
||||
square_last_sync_at: string | null;
|
||||
square_last_sync_error: string | null;
|
||||
} | null>(null);
|
||||
const [logs, setLogs] = useState<SyncLogEntry[]>([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [queueCount, setQueueCount] = useState(0);
|
||||
|
||||
const checkQueueCount = useCallback(async () => {
|
||||
const { supabase } = await import("@/lib/supabase");
|
||||
const result = await supabase
|
||||
.from("square_sync_queue")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("brand_id", brandId)
|
||||
.in("status", ["pending"]) as unknown as { count: number | null };
|
||||
setQueueCount(result.count ?? 0);
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial data load — runs once on mount. Subsequent refreshes
|
||||
// happen inside the event handlers (handleSyncNow) which is the
|
||||
// proper React pattern: side effects live in event handlers, not
|
||||
// in effects that watch props.
|
||||
void (async () => {
|
||||
const [settingsResult, logsResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
getSyncLog(brandId),
|
||||
]);
|
||||
const nextSettings =
|
||||
settingsResult.success && settingsResult.settings
|
||||
? {
|
||||
provider: settingsResult.settings.provider ?? null,
|
||||
square_access_token: settingsResult.settings.square_access_token ?? null,
|
||||
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
|
||||
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
|
||||
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
|
||||
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
|
||||
}
|
||||
: null;
|
||||
const nextLogs = logsResult.success ? logsResult.logs.slice(0, 5) : [];
|
||||
setSettings(nextSettings);
|
||||
setLogs(nextLogs);
|
||||
})();
|
||||
// brandId is stable for this widget's lifetime; mount-only load.
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Poll pending queue count every 30s
|
||||
const interval = setInterval(async () => {
|
||||
await checkQueueCount();
|
||||
}, 30000);
|
||||
(async () => {
|
||||
await checkQueueCount();
|
||||
})();
|
||||
return () => clearInterval(interval);
|
||||
}, [checkQueueCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncMsg) return;
|
||||
const timer = setTimeout(() => setSyncMsg(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [syncMsg]);
|
||||
|
||||
async function handleSyncNow(type: "products" | "orders" | "all") {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncSquareNow(brandId, type);
|
||||
setSyncing(false);
|
||||
setSyncMsg({
|
||||
kind: result.success ? "success" : "error",
|
||||
text: result.success
|
||||
? `Sync complete — ${result.synced} item(s) synced.`
|
||||
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
|
||||
});
|
||||
const [logsResult, settingsResult] = await Promise.all([
|
||||
getSyncLog(brandId),
|
||||
getPaymentSettings(brandId),
|
||||
]);
|
||||
if (logsResult.success) {
|
||||
setLogs(logsResult.logs.slice(0, 5));
|
||||
}
|
||||
if (settingsResult.success && settingsResult.settings) {
|
||||
setSettings({
|
||||
provider: settingsResult.settings.provider ?? null,
|
||||
square_access_token: settingsResult.settings.square_access_token ?? null,
|
||||
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
|
||||
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
|
||||
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
|
||||
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lastSyncAt = settings?.square_last_sync_at
|
||||
? timeAgo(settings.square_last_sync_at)
|
||||
: "Never";
|
||||
const lastError = settings?.square_last_sync_error;
|
||||
const hasToken = !!(settings?.square_access_token);
|
||||
const autoSyncEnabled = settings?.square_sync_enabled && hasToken;
|
||||
|
||||
const subheading = hasToken
|
||||
? autoSyncEnabled
|
||||
? `Wholesale products · ${lastSyncAt}`
|
||||
: "Connected — auto-sync disabled"
|
||||
: "Not connected";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-900/40">
|
||||
<svg className="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-zinc-100">Square Inventory</h3>
|
||||
{autoSyncEnabled && (
|
||||
<span className="rounded-full bg-green-900/40 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||
Auto-sync
|
||||
</span>
|
||||
)}
|
||||
{!autoSyncEnabled && hasToken && (
|
||||
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-xs font-medium text-zinc-500">
|
||||
Sync off
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{subheading}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{lastError && (
|
||||
<span className="rounded-full bg-red-900/40 px-2.5 py-0.5 text-xs font-medium text-red-400">
|
||||
Sync Error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastError && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-900/30 px-3 py-2 text-xs text-red-400">
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncMsg && (
|
||||
<div className={`mb-3 rounded-lg border px-3 py-2 text-sm ${
|
||||
syncMsg.kind === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToken ? (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button"
|
||||
onClick={() => handleSyncNow("products")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync Products"}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleSyncNow("orders")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync Orders"}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleSyncNow("all")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync All"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!autoSyncEnabled && hasToken && (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-900/30 px-3 py-2 text-xs text-amber-700">
|
||||
Square sync is turned off. Wholesale products will not be pushed to Square automatically.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Recent activity
|
||||
</p>
|
||||
{logs.slice(0, 4).map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
|
||||
log.status === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
[{log.direction ?? "—"}] {log.event_type}
|
||||
</span>
|
||||
<span className="ml-2 text-slate-400">
|
||||
{timeAgo(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
href="/admin/settings/square-sync"
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
View Settings
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/orders?square=1"
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Square Orders
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin/settings/payments"
|
||||
className="inline-block rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
|
||||
>
|
||||
Connect Square
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
type HeroSectionProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
heroImageUrl?: string | null;
|
||||
heroVideoUrl?: string | null;
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick?: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
brandAccent?: "green" | "orange" | "blue";
|
||||
};
|
||||
|
||||
export default function HeroSection({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
heroImageUrl,
|
||||
heroVideoUrl,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
brandAccent = "blue",
|
||||
}: HeroSectionProps) {
|
||||
const hasHeroImage = !!heroImageUrl;
|
||||
const hasHeroVideo = !!heroVideoUrl;
|
||||
const isBlue = brandAccent === "blue";
|
||||
|
||||
const btnBg = isBlue ? "bg-blue-600 hover:bg-blue-500 active:bg-blue-700" : "bg-stone-900 hover:bg-stone-800 active:bg-stone-950";
|
||||
|
||||
if (hasHeroImage) {
|
||||
return (
|
||||
<section
|
||||
className="relative flex min-h-[560px] items-center overflow-hidden py-28"
|
||||
style={{
|
||||
backgroundImage: `url(${heroImageUrl})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center top",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0" style={{
|
||||
background: isBlue
|
||||
? "linear-gradient(135deg, rgba(0,119,190,0.25) 0%, rgba(0,119,190,0.08) 40%, transparent 70%), linear-gradient(to top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.12) 55%, transparent 100%)"
|
||||
: "linear-gradient(135deg, rgba(0,0,0,0.20) 0%, transparent 45%), linear-gradient(to top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.15) 55%, transparent 100%)"
|
||||
}} />
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 relative z-10">
|
||||
{eyebrow && (
|
||||
<p className={`text-[10px] sm:text-xs font-bold uppercase tracking-[0.25em] mb-4 sm:mb-6 ${isBlue ? "text-blue-200" : "text-white/60"}`}>
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-white leading-[1.05] max-w-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 sm:mt-8 text-lg sm:text-xl md:text-2xl text-white/70 leading-relaxed max-w-lg font-light">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="mt-10 sm:mt-12 flex flex-wrap gap-3 sm:gap-4">
|
||||
{primaryButton && (
|
||||
<button type="button"
|
||||
onClick={onPrimaryClick}
|
||||
className={`rounded-2xl ${btnBg} active:scale-95 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white transition-all`}
|
||||
>
|
||||
{primaryButton}
|
||||
</button>
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<button type="button"
|
||||
onClick={onSecondaryClick}
|
||||
className="rounded-2xl bg-white/10 backdrop-blur-sm border border-white/25 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white hover:bg-white/20 hover:border-white/40 active:scale-95 transition-all"
|
||||
>
|
||||
{secondaryButton}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasHeroVideo) {
|
||||
return (
|
||||
<section className="relative flex min-h-[560px] items-center overflow-hidden py-28">
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
src={heroVideoUrl}
|
||||
/>
|
||||
<div className="absolute inset-0" style={{
|
||||
background: "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 35%, rgba(0,0,0,0.6) 72%, rgba(0,0,0,0.88) 100%)"
|
||||
}} />
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 relative z-10">
|
||||
{eyebrow && (
|
||||
<p className="text-[10px] sm:text-xs font-bold uppercase tracking-[0.25em] mb-4 sm:mb-6 text-blue-200">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-white leading-[1.05] max-w-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 sm:mt-8 text-lg sm:text-xl md:text-2xl text-white/70 leading-relaxed max-w-lg font-light">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="mt-10 sm:mt-12 flex flex-wrap gap-3 sm:gap-4">
|
||||
{primaryButton && (
|
||||
<button type="button"
|
||||
onClick={onPrimaryClick}
|
||||
className="rounded-2xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 active:scale-95 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white transition-all"
|
||||
>
|
||||
{primaryButton}
|
||||
</button>
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<button type="button"
|
||||
onClick={onSecondaryClick}
|
||||
className="rounded-2xl bg-white/10 backdrop-blur-sm border border-white/25 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white hover:bg-white/20 hover:border-white/40 active:scale-95 transition-all"
|
||||
>
|
||||
{secondaryButton}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-16 sm:py-20 md:py-28 px-4 sm:px-6 bg-gradient-to-br from-stone-100 via-stone-50 to-stone-100">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{eyebrow && (
|
||||
<p className={`text-[10px] sm:text-xs font-bold uppercase tracking-[0.25em] mb-4 sm:mb-6 ${isBlue ? "text-blue-500" : "text-emerald-600"}`}>
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black tracking-tight leading-[1.05] max-w-3xl text-stone-950">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 sm:mt-8 text-lg sm:text-xl md:text-2xl leading-relaxed max-w-lg font-light text-stone-600">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="mt-10 sm:mt-12 flex flex-wrap gap-3 sm:gap-4">
|
||||
{primaryButton && (
|
||||
<button type="button"
|
||||
onClick={onPrimaryClick}
|
||||
className={`rounded-2xl ${btnBg} active:scale-95 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white transition-all`}
|
||||
>
|
||||
{primaryButton}
|
||||
</button>
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<button type="button"
|
||||
onClick={onSecondaryClick}
|
||||
className="rounded-2xl bg-white px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-stone-950 ring-1 ring-stone-300 hover:bg-stone-50 active:scale-95 transition-all"
|
||||
>
|
||||
{secondaryButton}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user