fix: react-doctor unused-file 27→0 (-27 more dead files, wholesale consolidate)

This commit is contained in:
Nora
2026-06-26 04:44:15 -06:00
parent 27b2ded94e
commit 3249ff0a55
27 changed files with 0 additions and 5352 deletions
-288
View File
@@ -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>
);
}
-419
View File
@@ -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 };
}
-574
View File
@@ -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&apos;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&apos;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>
);
}
-471
View File
@@ -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&apos;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 &quot;Connect with Stripe&quot; 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>
);
}
-212
View File
@@ -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>
);
}
-184
View File
@@ -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>
);
}
-301
View File
@@ -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>
);
}
-267
View File
@@ -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`;
}