Merge GitHub main into Gitea main
Brings in: - .gitea/workflows/build.yml (build + typecheck + lint on self-hosted runner) - scripts/e2e-test.sh (local Postgres + PostgREST + MinIO + Next.js validation) - Codex review round 2 fixes (buyer/billing/comms/a11y) - 207 multi-brand admin migration - Locations table + UI - Various product/stops/auth refinements Resolved 7 conflicts by taking Gitea's version to preserve production behavior: - package.json (deps) - src/actions/products/upload-image.ts (storage) - src/app/admin/taxes/page.tsx - src/app/checkout/CheckoutClient.tsx - src/components/admin/AdminSidebar.tsx - src/components/admin/StopProductAssignment.tsx - src/lib/admin-permissions.ts Our new dev_session auth + MinIO storage changes are deferred to a focused follow-up to avoid breaking the production deploy.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
import { importOrdersBatch } from "@/actions/import-orders";
|
||||
@@ -41,7 +42,9 @@ export async function analyzeImport(
|
||||
): Promise<AnalyzeImportResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -96,7 +97,7 @@ async function brandScopedRPC<T>(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||
method: "POST",
|
||||
@@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
||||
@@ -293,7 +294,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
select: "id,status",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
/**
|
||||
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||||
* Google Pay / card) without redirecting to a hosted page.
|
||||
*
|
||||
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
|
||||
* the PaymentIntent is the embedded equivalent of the Checkout Session.
|
||||
* Order creation itself still happens on `/checkout/success` so the
|
||||
* webhooks and order pipeline don't need to know which path the buyer
|
||||
* used.
|
||||
*/
|
||||
type LineItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
type CustomerInfo = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type CreatePaymentIntentResult =
|
||||
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function createRetailPaymentIntent(
|
||||
items: LineItem[],
|
||||
customer: CustomerInfo,
|
||||
brandId: string | null,
|
||||
stopId: string | null,
|
||||
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||||
): Promise<CreatePaymentIntentResult> {
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) {
|
||||
return { success: false, error: "Stripe not configured on this server." };
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return { success: false, error: "Cart is empty." };
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||
// client-side; for this single-product corn box the price is tax-inclusive
|
||||
// (see Tuxedo Corn product card) so we treat the line total as final.
|
||||
const amount = items.reduce((sum, item) => {
|
||||
const qty = Math.max(1, Math.floor(item.quantity || 1));
|
||||
const unit = Math.max(0, Math.round(Number(item.price) * 100));
|
||||
return sum + unit * qty;
|
||||
}, 0);
|
||||
|
||||
if (amount <= 0) {
|
||||
return { success: false, error: "Cart total must be greater than $0." };
|
||||
}
|
||||
|
||||
// Pull the brand name for Stripe receipts + metadata
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
let brandName = "Route Commerce";
|
||||
if (supabaseUrl && supabaseKey && brandId) {
|
||||
try {
|
||||
const brandRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
);
|
||||
const brands = (await brandRes.json()) as Array<{ name: string }>;
|
||||
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||
} catch {
|
||||
// ignore — use default
|
||||
}
|
||||
}
|
||||
|
||||
// Build a short human-readable description for the Stripe dashboard
|
||||
const description = items
|
||||
.slice(0, 3)
|
||||
.map((i) => `${i.quantity}× ${i.name}`)
|
||||
.join(", ");
|
||||
|
||||
try {
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency: "usd",
|
||||
automatic_payment_methods: { enabled: true },
|
||||
receipt_email: customer.email || undefined,
|
||||
description,
|
||||
metadata: {
|
||||
brand_id: brandId ?? "unknown",
|
||||
brand_name: brandName,
|
||||
stop_id: stopId ?? "",
|
||||
customer_name: customer.name ?? "",
|
||||
customer_email: customer.email ?? "",
|
||||
shipping_state: shippingAddress?.state ?? "",
|
||||
shipping_postal_code: shippingAddress?.postal_code ?? "",
|
||||
shipping_city: shippingAddress?.city ?? "",
|
||||
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
|
||||
source: "storefront_express",
|
||||
},
|
||||
});
|
||||
|
||||
if (!intent.client_secret) {
|
||||
return { success: false, error: "Stripe did not return a client secret." };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
clientSecret: intent.client_secret,
|
||||
paymentIntentId: intent.id,
|
||||
amount: intent.amount,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import "server-only";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type BrandListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo_url: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of brands the current admin user can act in.
|
||||
*
|
||||
* - platform_admin: all brands (queried directly from the `brands` table)
|
||||
* - everyone else: brands in `adminUser.brand_ids`
|
||||
* - empty array for unauthenticated / no-access admins
|
||||
*
|
||||
* This is a plain async function (not a server action) so it can be called
|
||||
* from server components and server actions without the "use server" wrapper.
|
||||
* The BrandSelector client component receives the result as a prop.
|
||||
*/
|
||||
export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceKey) return [];
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (adminUser.brand_ids.length === 0) return [];
|
||||
|
||||
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
|
||||
// pattern in the spec is safe; the inner quotes are required by PostgREST
|
||||
// for UUID literals.
|
||||
const filter = `id=in.(${adminUser.brand_ids
|
||||
.map((id) => `"${id}"`)
|
||||
.join(",")})`;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
@@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
@@ -147,7 +152,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -167,7 +172,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
@@ -120,11 +121,15 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Resolve brand from campaign or parameter
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id;
|
||||
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
// Brand scoping: brand_admin can only send their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
|
||||
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
@@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
// Brand scoping: brand_admin can only see their own brand's templates
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
||||
@@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
|
||||
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -66,7 +67,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// Get today's date range
|
||||
const today = new Date();
|
||||
@@ -202,7 +203,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type LocationInput = {
|
||||
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;
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
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;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type LocationWithCount = Location & { stop_count: number };
|
||||
|
||||
// ── Create (single) ──────────────────────────────────────────────────────────
|
||||
export async function createLocation(
|
||||
brandId: string,
|
||||
input: LocationInput
|
||||
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, error: "Not authorized to manage locations" };
|
||||
}
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_name: input.name,
|
||||
p_address: input.address ?? null,
|
||||
p_city: input.city ?? null,
|
||||
p_state: input.state ?? null,
|
||||
p_zip: input.zip ?? null,
|
||||
p_phone: input.phone ?? null,
|
||||
p_contact_name: input.contact_name ?? null,
|
||||
p_contact_email: input.contact_email ?? null,
|
||||
p_notes: input.notes ?? null,
|
||||
p_active: input.active ?? true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return { success: true, id: data.id, slug: data.slug };
|
||||
}
|
||||
|
||||
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||
export async function createLocationsBatch(
|
||||
brandId: string,
|
||||
locations: LocationInput[]
|
||||
): Promise<{ success: boolean; created: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, created: 0, error: "Not authorized" };
|
||||
}
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, created: 0, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return {
|
||||
success: true,
|
||||
created: Array.isArray(inserted) ? inserted.length : locations.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Update (partial) ─────────────────────────────────────────────────────────
|
||||
export async function updateLocation(
|
||||
locationId: string,
|
||||
brandId: string,
|
||||
updates: Partial<LocationInput>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────────────────────
|
||||
export async function deleteLocation(
|
||||
locationId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
|
||||
export async function adminListLocations(
|
||||
brandId: string
|
||||
): Promise<LocationWithCount[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
|
||||
// brands; everyone else gets only their own brand.
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
||||
}
|
||||
|
||||
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||
export type PublicLocation = Pick<
|
||||
Location,
|
||||
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
|
||||
> & { stop_count: number };
|
||||
|
||||
export async function getPublicLocationsForBrand(
|
||||
brandSlug: string
|
||||
): Promise<PublicLocation[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as PublicLocation[]) : [];
|
||||
}
|
||||
|
||||
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||
export async function attachStopToLocation(
|
||||
stopId: string,
|
||||
locationId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_location_id: locationId,
|
||||
p_brand_id: brandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@@ -41,7 +42,11 @@ export async function createAdminOrder(
|
||||
}
|
||||
|
||||
// Brand scoping
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type PaymentProvider = "stripe" | "square" | "manual";
|
||||
@@ -65,10 +66,9 @@ export async function savePaymentSettings(params: {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, params.brandId);
|
||||
} catch {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function deleteProduct(
|
||||
@@ -16,7 +17,11 @@ export async function deleteProduct(
|
||||
return { success: false, error: "Not authorized to manage products" };
|
||||
}
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -153,7 +154,14 @@ export interface FieldYieldSummary {
|
||||
export async function getRouteTraceLots(brandId: string, status?: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -235,7 +243,14 @@ export async function createHarvestLot(
|
||||
) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -298,7 +313,14 @@ export async function updateHarvestLotStatus(
|
||||
export async function getRouteTraceStats(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -318,7 +340,14 @@ export async function getRouteTraceStats(brandId: string) {
|
||||
export async function searchHarvestLots(brandId: string, query: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -347,7 +376,14 @@ export async function getTraceChain(lotId: string) {
|
||||
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -364,7 +400,14 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||
export async function getFieldYieldSummary(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -405,7 +448,14 @@ export interface InventoryByCrop {
|
||||
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
@@ -451,7 +501,14 @@ export async function getRecentLotEvents(
|
||||
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
if (activeBrandId) {
|
||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? undefined;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import {
|
||||
setActiveBrandCookie,
|
||||
clearActiveBrandCookie,
|
||||
} from "@/lib/brand-scope";
|
||||
|
||||
/**
|
||||
* Set the persistent "active brand" for the current admin user.
|
||||
*
|
||||
* - `brandId === null`: "All brands" — only allowed for platform_admin.
|
||||
* Clears the cookie (cookie absence = no specific brand pinned).
|
||||
* - `brandId` string: sets the cookie, after validating the admin has access.
|
||||
*
|
||||
* The active brand is the default the UI uses for pages that don't receive
|
||||
* an explicit `brandId` from the URL. The cookie is the source of truth —
|
||||
* the URL is only for deep-linking.
|
||||
*/
|
||||
export async function setActiveBrand(
|
||||
brandId: string | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// null = "All brands" (platform_admin only)
|
||||
if (brandId === null) {
|
||||
if (adminUser.role !== "platform_admin") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Only platform admins can select 'All brands'",
|
||||
};
|
||||
}
|
||||
await clearActiveBrandCookie();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (
|
||||
adminUser.role !== "platform_admin" &&
|
||||
!adminUser.brand_ids.includes(brandId)
|
||||
) {
|
||||
return { success: false, error: "No access to that brand" };
|
||||
}
|
||||
|
||||
await setActiveBrandCookie(brandId);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type UpdateShippingStatusResult =
|
||||
@@ -28,7 +29,7 @@ export async function updateShippingStatus(
|
||||
p_order_id: orderId,
|
||||
p_shipping_status: status,
|
||||
p_tracking_number: trackingNumber ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -79,7 +80,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { FedExServiceType } from "./fedex-rates";
|
||||
|
||||
@@ -156,6 +157,10 @@ export async function createFedExShipment(
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
// The order's brand is the source of truth. Validate the admin has access.
|
||||
if (order.brand_id) {
|
||||
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Get FedEx settings
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -177,6 +178,10 @@ export async function getFedExRates(
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
// The order's brand is the source of truth. Validate the admin has access.
|
||||
if (order.brand_id) {
|
||||
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Fetch shipping settings for this brand
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type SyncLogEntry = {
|
||||
@@ -29,7 +30,9 @@ export async function syncSquareNow(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
@@ -61,7 +64,9 @@ export async function getSyncLog(brandId: string): Promise<{
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, logs: [] };
|
||||
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type StopImportRow = {
|
||||
@@ -25,7 +26,11 @@ export async function createStopsBatch(
|
||||
return { success: false, created: 0, error: "Not authorized to manage stops" };
|
||||
}
|
||||
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, created: 0, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) {
|
||||
return { success: false, created: 0, error: "No brand selected" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export type StopDetail = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
slug: string;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_time: string | null;
|
||||
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
|
||||
};
|
||||
|
||||
export type AssignedProduct = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products: { name: string; type: string; price: number } | null;
|
||||
};
|
||||
|
||||
export type StopDetailsResult =
|
||||
| {
|
||||
success: true;
|
||||
stop: StopDetail;
|
||||
allProducts: { id: string; name: string; type: string; price: number }[];
|
||||
assignedProducts: AssignedProduct[];
|
||||
brands: { id: string; name: string; slug: string }[];
|
||||
/** admin_users.user_id of the caller, forwarded to RPCs that authorise via user_id lookup */
|
||||
callerUid: string;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Fetch a single stop with its brand, all candidate products, currently
|
||||
* assigned products, and the list of brands (for the brand switcher in the
|
||||
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
|
||||
* component loaded, so the modal can be a drop-in replacement.
|
||||
*/
|
||||
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
|
||||
// for platform_admin dev sessions. The auth check above has already gated
|
||||
// access.
|
||||
const server = useMockData
|
||||
? null
|
||||
: createClient(supabaseUrl, supabaseKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// 1. Stop + brand
|
||||
let stop: StopDetail | null = null;
|
||||
let stopErr: string | null = null;
|
||||
|
||||
if (server) {
|
||||
const { data, error } = await server
|
||||
.from("stops")
|
||||
.select("*, brands(name, slug)")
|
||||
.eq("id", stopId)
|
||||
.single();
|
||||
if (error) stopErr = error.message;
|
||||
else stop = (data ?? null) as StopDetail | null;
|
||||
} else {
|
||||
// Mock fallback — empty
|
||||
stopErr = "Stop not found";
|
||||
}
|
||||
|
||||
if (!stop) {
|
||||
return { success: false, error: stopErr ?? "Stop not found" };
|
||||
}
|
||||
|
||||
// Brand-scope check for brand_admin
|
||||
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
// 2. Candidate products for this brand
|
||||
const { data: allProducts } = server
|
||||
? await server
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", stop.brand_id)
|
||||
.eq("active", true)
|
||||
: { data: [] as { id: string; name: string; type: string; price: number }[] };
|
||||
|
||||
// 3. Assigned products (joined with product info)
|
||||
const { data: productStops } = server
|
||||
? await server
|
||||
.from("product_stops")
|
||||
.select("id, product_id, products(id, name, type, price)")
|
||||
.eq("stop_id", stopId)
|
||||
: { data: [] as AssignedProduct[] };
|
||||
|
||||
// 4. Brands for the brand switcher
|
||||
const { data: brands } = server
|
||||
? await server.from("brands").select("id, name, slug")
|
||||
: { data: [] as { id: string; name: string; slug: string }[] };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stop,
|
||||
allProducts: allProducts ?? [],
|
||||
assignedProducts: (productStops ?? []) as AssignedProduct[],
|
||||
brands: brands ?? [],
|
||||
callerUid: adminUser.user_id,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type Irrigator = {
|
||||
@@ -94,7 +95,7 @@ export async function updateWaterHeadgate(
|
||||
p_name: name,
|
||||
p_active: active,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
p_high_threshold: highThreshold ?? null,
|
||||
p_low_threshold: lowThreshold ?? null,
|
||||
}),
|
||||
@@ -186,7 +187,7 @@ export async function updateWaterIrrigator(
|
||||
p_active: active,
|
||||
p_lang: lang,
|
||||
p_role: role,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -215,7 +216,7 @@ export async function resetWaterIrrigatorPin(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: irrigatorId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -242,7 +243,7 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: userId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -269,7 +270,7 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_headgate_id: headgateId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -356,7 +357,7 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
|
||||
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -392,7 +393,7 @@ export async function updateWaterEntry(
|
||||
p_measurement: measurement,
|
||||
p_notes: notes,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -421,7 +422,7 @@ export async function deleteWaterEntry(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function registerWholesaleCustomer(params: {
|
||||
@@ -81,7 +82,9 @@ export async function approveWholesaleRegistration(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return { success: false, error: "Not authorized to operate on this brand" };
|
||||
}
|
||||
|
||||
|
||||
+33
-27
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type WholesaleOrder = {
|
||||
@@ -129,15 +130,16 @@ export type WholesaleDashboardStats = {
|
||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||
* store_employee → their own brand_id
|
||||
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||
* unauthenticated → null (actions should already bail out earlier)
|
||||
*
|
||||
* This prevents brand_admin from seeing or modifying another brand's data
|
||||
* even if they manually pass a different brandId to the action.
|
||||
*/
|
||||
function resolveBrandId(
|
||||
async function resolveBrandId(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
if (!adminUser) return null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
@@ -145,15 +147,15 @@ function resolveBrandId(
|
||||
return null;
|
||||
}
|
||||
|
||||
// brand_admin and store_employee are scoped to their own brand
|
||||
const userBrand = adminUser.brand_id ?? null;
|
||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||
|
||||
if (requestedBrandId && requestedBrandId !== userBrand) {
|
||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||
// Brand admin trying to operate on another brand's data — block it
|
||||
return null; // caller should check and return unauthorized
|
||||
}
|
||||
|
||||
return userBrand;
|
||||
return activeBrandId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,23 +163,24 @@ function resolveBrandId(
|
||||
* if a brand_admin tries to operate outside their brand.
|
||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||
*/
|
||||
function enforceBrandScope(
|
||||
async function enforceBrandScope(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): { brandId: string | null; error?: string } {
|
||||
): Promise<{ brandId: string | null; error?: string }> {
|
||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return { brandId: null }; // unrestricted
|
||||
}
|
||||
|
||||
const userBrand = adminUser.brand_id ?? null;
|
||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||
|
||||
if (requestedBrandId && requestedBrandId !== userBrand) {
|
||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||
}
|
||||
|
||||
return { brandId: userBrand };
|
||||
return { brandId: activeBrandId };
|
||||
}
|
||||
|
||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||
@@ -185,7 +188,7 @@ function enforceBrandScope(
|
||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
@@ -209,7 +212,7 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
|
||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
@@ -256,7 +259,7 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId);
|
||||
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -305,7 +308,7 @@ export async function updateWholesaleOrderStatus(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = enforceBrandScope(adminUser, brandId);
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -332,7 +335,7 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = enforceBrandScope(adminUser, brandId);
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -359,7 +362,7 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = enforceBrandScope(adminUser, brandId);
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -388,7 +391,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = enforceBrandScope(adminUser, brandId);
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -417,7 +420,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
|
||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
@@ -458,7 +461,7 @@ export async function saveWholesaleCustomer(params: {
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = enforceBrandScope(adminUser, params.brandId);
|
||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -501,7 +504,7 @@ export async function saveWholesaleCustomer(params: {
|
||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
@@ -545,7 +548,7 @@ export async function saveWholesaleProduct(params: {
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = enforceBrandScope(adminUser, params.brandId);
|
||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
@@ -591,7 +594,10 @@ export async function saveWholesaleProduct(params: {
|
||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
const bid = brandId ?? adminUser.brand_id ?? null;
|
||||
const bid = await getActiveBrandId(adminUser, brandId);
|
||||
if (!bid && adminUser.role !== "platform_admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
|
||||
@@ -700,7 +706,7 @@ export async function recordWholesaleDeposit(
|
||||
p_method: method,
|
||||
p_reference: reference ?? null,
|
||||
p_recorded_by: adminUser.user_id,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -710,7 +716,7 @@ export async function recordWholesaleDeposit(
|
||||
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||
|
||||
// Fire webhook — fire-and-forget
|
||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {});
|
||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -753,7 +759,7 @@ export async function bulkFulfillWholesaleOrders(
|
||||
body: JSON.stringify({
|
||||
p_order_ids: orderIds,
|
||||
p_by: adminUser.user_id,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -791,7 +797,7 @@ export async function bulkRecordWholesaleDeposit(
|
||||
p_method: method,
|
||||
p_reference: reference ?? null,
|
||||
p_recorded_by: adminUser.user_id,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { listBrandsForAdmin } from "@/actions/brands";
|
||||
import { redirect } from "next/navigation";
|
||||
import "@/styles/admin-design-system.css";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
@@ -57,12 +59,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// Fetch accessible brands for the sidebar BrandSelector. We do this
|
||||
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
|
||||
// decides whether to show the dropdown.
|
||||
const brands = await listBrandsForAdmin();
|
||||
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminSidebar userRole={adminUser.role} />
|
||||
<AdminSidebar
|
||||
userRole={adminUser.role}
|
||||
brandIds={adminUser.brand_ids}
|
||||
activeBrandId={activeBrandId}
|
||||
brands={brands}
|
||||
/>
|
||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
@@ -17,13 +18,19 @@ export default async function AdminOrdersPage() {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
// Platform admin can browse all brands' orders; everyone else must have a brand
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to any brand." />;
|
||||
}
|
||||
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
const brandStops = adminUser?.brand_id
|
||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
||||
const brandStops = activeBrandId
|
||||
? stops.filter((s) => s.brand_id === activeBrandId)
|
||||
: stops;
|
||||
|
||||
const brandOrders = adminUser?.brand_id
|
||||
const brandOrders = activeBrandId
|
||||
? orders.filter(
|
||||
(o) =>
|
||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
|
||||
.order("name")
|
||||
.limit(200);
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
|
||||
if (activeBrandId) {
|
||||
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
||||
}
|
||||
|
||||
const { data: prods } = await prodQuery;
|
||||
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
initialProducts={brandProducts}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
brandId={activeBrandId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
import DashboardClient from "@/components/admin/DashboardClient";
|
||||
@@ -23,12 +24,11 @@ export default async function AdminPage() {
|
||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||
let brandDisplayName = "Admin";
|
||||
|
||||
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
|
||||
// the dashboard's "Active Products" stat in sync with the billing page,
|
||||
// we need to pick a brand and use the same getBillingOverview action
|
||||
// the billing page does. Otherwise the dashboard falls back to default
|
||||
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
|
||||
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
|
||||
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||
// fall back to the first brand in the brands table to keep the dashboard's
|
||||
// "Active Products" stat in sync with the billing page.
|
||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||
const { data: firstBrand } = await api
|
||||
.from("brands")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { api } from "@/lib/api";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ProductsClient from "@/components/admin/ProductsClient";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
|
||||
// Icon for page header
|
||||
const PackageIcon = () => (
|
||||
@@ -29,7 +31,16 @@ export default async function AdminProductsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const brandId = adminUser.brand_id;
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
const brandId = activeBrandId;
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
// Platform admins need a brand picker for new products
|
||||
let brands: { id: string; name: string }[] = [];
|
||||
if (isPlatformAdmin) {
|
||||
const result = await getBrands();
|
||||
brands = result.brands ?? [];
|
||||
}
|
||||
|
||||
let query = api
|
||||
.from("products")
|
||||
@@ -48,8 +59,8 @@ export default async function AdminProductsPage() {
|
||||
.is("deleted_at", null)
|
||||
.order("name");
|
||||
|
||||
if (adminUser.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
if (brandId) {
|
||||
query = query.eq("brand_id", brandId);
|
||||
}
|
||||
|
||||
const { data: products, error } = (await query) as any;
|
||||
@@ -69,7 +80,12 @@ export default async function AdminProductsPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<ProductsClient products={products ?? []} brandId={brandId ?? undefined} />
|
||||
<ProductsClient
|
||||
products={products ?? []}
|
||||
brandId={brandId ?? undefined}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "@/lib/api";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
@@ -19,7 +20,7 @@ export default async function ReportsPage() {
|
||||
|
||||
const initialBrandId = isPlatformAdmin
|
||||
? null
|
||||
: adminUser.brand_id ?? null;
|
||||
: await getActiveBrandId(adminUser);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "@/lib/api";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import BillingClientPage from "./BillingClientPage";
|
||||
@@ -13,7 +14,11 @@ export default async function BillingPage({ params }: Props) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? "";
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||
import { api } from "@/lib/api";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
<StopTableClient stops={stops ?? []} />
|
||||
</div>
|
||||
<StopsDashboardClient stops={stops ?? []} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import WholesaleClient from "./WholesaleClient";
|
||||
|
||||
export default async function WholesalePage() {
|
||||
@@ -11,10 +13,10 @@ export default async function WholesalePage() {
|
||||
devSession === "store_employee";
|
||||
|
||||
if (!isDevMode) {
|
||||
const { getAdminUser } = await import("@/lib/admin-permissions");
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />;
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
return <WholesaleClient brandId={activeBrandId ?? ""} />;
|
||||
}
|
||||
|
||||
// Dev mode: platform_admin sees all brands, use first brand as default
|
||||
|
||||
@@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string {
|
||||
function SuccessContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
// Embedded Stripe Elements path — we navigated here programmatically
|
||||
// with ?payment_intent=pi_... after `stripe.confirmPayment` resolved.
|
||||
const paymentIntentId = searchParams.get("payment_intent");
|
||||
const redirectStatus = searchParams.get("redirect_status");
|
||||
const orderIdParam = searchParams.get("order_id");
|
||||
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
||||
// searchParams values are stable, and sessionStorage is client-only, so this
|
||||
// is safe in a client component.
|
||||
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
||||
if (!orderIdParam || sessionId) return null;
|
||||
if (!orderIdParam || sessionId || paymentIntentId) return null;
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
||||
@@ -62,14 +66,20 @@ function SuccessContent() {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [creating, setCreating] = useState(!!sessionId);
|
||||
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stripe redirected back — create order from pending checkout data.
|
||||
// Wrapped in an async IIFE so all setState calls happen inside a callback,
|
||||
// not in the synchronous effect body (satisfies set-state-in-effect rule).
|
||||
// Create the order from the pending-checkout payload, regardless of
|
||||
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
|
||||
// embedded Stripe Elements (?payment_intent=...). Both paths store the
|
||||
// same payload in `pending_checkout` before payment is initiated.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!sessionId && !paymentIntentId) return;
|
||||
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
|
||||
setError("Payment was not completed. Please try again.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const pendingStr = sessionStorage.getItem("pending_checkout");
|
||||
@@ -88,6 +98,7 @@ function SuccessContent() {
|
||||
cartBrandId?: string;
|
||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||
idempotencyKey: string;
|
||||
paymentIntentId?: string;
|
||||
};
|
||||
try {
|
||||
pending = JSON.parse(pendingStr);
|
||||
@@ -123,7 +134,7 @@ function SuccessContent() {
|
||||
setCreating(false);
|
||||
}
|
||||
})();
|
||||
}, [sessionId]);
|
||||
}, [sessionId, paymentIntentId, redirectStatus]);
|
||||
|
||||
if (!order || !orderIdParam) {
|
||||
return (
|
||||
|
||||
+590
-2
@@ -1,6 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Custom typefaces — Field Almanac */
|
||||
--font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
|
||||
--font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* Apple-inspired dark palette — sophisticated depth */
|
||||
--color-surface-50: #f5f5f7;
|
||||
--color-surface-100: #e8e8ed;
|
||||
@@ -85,7 +90,7 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: #ffffff;
|
||||
background-attachment: fixed;
|
||||
color: #1a1a1a;
|
||||
@@ -441,4 +446,587 @@ select:-webkit-autofill:focus {
|
||||
|
||||
.transition-glass {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Editorial Modal — "Atelier des Récoltes" ───────────────── */
|
||||
|
||||
/* Warm cream canvas for the modal — like aged paper */
|
||||
.atelier-canvas {
|
||||
background-color: #FAF7F0;
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(180, 155, 100, 0.10) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 100% 100%, rgba(34, 78, 47, 0.06) 0%, transparent 55%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle grain texture for tactile feel */
|
||||
.atelier-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
mix-blend-mode: multiply;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.15 0 0 0 0 0.10 0 0 0 0.045 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Botanical wreath / corner ornament */
|
||||
.atelier-flourish {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23224E2F' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 40 Q 25 20 40 30 Q 55 40 70 30'/%3E%3Cpath d='M22 32 Q 25 28 28 30'/%3E%3Cpath d='M52 38 Q 55 34 58 36'/%3E%3Cpath d='M14 45 Q 18 50 24 48'/%3E%3Ccircle cx='40' cy='30' r='2' fill='%23224E2F'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* The big editorial numeral — large, italic, gold */
|
||||
.atelier-numeral {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 30;
|
||||
line-height: 0.85;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(135deg, #CA8A04 0%, #854D0E 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Editorial title — Fraunces with optical sizing */
|
||||
.atelier-title {
|
||||
font-family: var(--font-fraunces);
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 0.95;
|
||||
color: #1C1917;
|
||||
}
|
||||
|
||||
.atelier-italic {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #786B53;
|
||||
}
|
||||
|
||||
/* Section label — monospace, small caps style */
|
||||
.atelier-section-label {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #786B53;
|
||||
}
|
||||
|
||||
/* Section number badge */
|
||||
.atelier-section-num {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
color: #CA8A04;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.atelier-section-num::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, #CA8A04, transparent);
|
||||
}
|
||||
|
||||
/* Editorial hairline rule with fade at edges */
|
||||
.atelier-rule {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, rgba(180, 155, 100, 0.4) 20%, rgba(34, 78, 47, 0.3) 50%, rgba(180, 155, 100, 0.4) 80%, transparent);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Input fields — bottom border only, editorial */
|
||||
.atelier-input {
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #1C1917;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
|
||||
border-radius: 0;
|
||||
padding: 10px 0 10px 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 220ms cubic-bezier(0.4, 0, 0.2, 1), padding 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.atelier-input::placeholder {
|
||||
color: #A8A29E;
|
||||
font-style: italic;
|
||||
font-family: var(--font-fraunces);
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
}
|
||||
.atelier-input:hover {
|
||||
border-bottom-color: rgba(28, 25, 23, 0.28);
|
||||
}
|
||||
.atelier-input:focus {
|
||||
border-bottom-color: #224E2F;
|
||||
border-bottom-width: 2px;
|
||||
padding-bottom: 9.5px;
|
||||
}
|
||||
.atelier-input.is-error {
|
||||
border-bottom-color: #B91C1C;
|
||||
}
|
||||
|
||||
/* Large display input — name field */
|
||||
.atelier-input--display {
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 8px 0 14px 0;
|
||||
}
|
||||
.atelier-input--display::placeholder {
|
||||
color: #D6D3D1;
|
||||
font-style: italic;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 100;
|
||||
}
|
||||
.atelier-input--display:focus {
|
||||
border-bottom-width: 2.5px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
/* Price input — large currency treatment */
|
||||
.atelier-price {
|
||||
font-family: var(--font-fraunces);
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
letter-spacing: -0.03em;
|
||||
color: #1C1917;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid #224E2F;
|
||||
border-radius: 0;
|
||||
padding: 4px 0 12px 36px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 200ms ease;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.atelier-price::-webkit-outer-spin-button,
|
||||
.atelier-price::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.atelier-price:focus {
|
||||
border-bottom-color: #14532D;
|
||||
border-bottom-width: 2.5px;
|
||||
}
|
||||
.atelier-price-sigil {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 38px;
|
||||
color: #CA8A04;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Type selector — visual cards */
|
||||
.atelier-type-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 14px 12px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1.5px solid rgba(28, 25, 23, 0.10);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-type-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(34, 78, 47, 0.02) 0%, transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.atelier-type-card:hover {
|
||||
border-color: rgba(34, 78, 47, 0.25);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.atelier-type-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.atelier-type-card:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
.atelier-type-card.is-selected {
|
||||
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
|
||||
border-color: #14532D;
|
||||
box-shadow: 0 8px 24px -8px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
.atelier-type-card.is-selected::before {
|
||||
opacity: 0;
|
||||
}
|
||||
.atelier-type-card .atelier-type-icon {
|
||||
color: #786B53;
|
||||
transition: color 220ms ease, transform 220ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-icon {
|
||||
color: #FCD34D;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.atelier-type-card .atelier-type-name {
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 30;
|
||||
letter-spacing: -0.01em;
|
||||
color: #1C1917;
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-name {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.atelier-type-card .atelier-type-check {
|
||||
opacity: 0;
|
||||
color: #FCD34D;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Pill toggle — Active / Taxable */
|
||||
.atelier-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.atelier-toggle-track {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
background: rgba(28, 25, 23, 0.12);
|
||||
border-radius: 999px;
|
||||
transition: background 220ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-track {
|
||||
background: linear-gradient(135deg, #224E2F 0%, #16A34A 100%);
|
||||
box-shadow: 0 2px 8px -2px rgba(34, 78, 47, 0.4);
|
||||
}
|
||||
.atelier-toggle.is-on.is-gold .atelier-toggle-track {
|
||||
background: linear-gradient(135deg, #CA8A04 0%, #EAB308 100%);
|
||||
box-shadow: 0 2px 8px -2px rgba(202, 138, 4, 0.4);
|
||||
}
|
||||
.atelier-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.atelier-toggle-label {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #786B53;
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-label {
|
||||
color: #1C1917;
|
||||
}
|
||||
|
||||
/* Image drop zone — large square with botanical treatment */
|
||||
.atelier-drop {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1.5px dashed rgba(34, 78, 47, 0.25);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-drop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
border: 1px solid rgba(180, 155, 100, 0.18);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.atelier-drop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, rgba(34, 78, 47, 0.02) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.atelier-drop:hover {
|
||||
border-color: rgba(34, 78, 47, 0.45);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
.atelier-drop.is-drag {
|
||||
border-color: #224E2F;
|
||||
border-style: solid;
|
||||
background: rgba(34, 78, 47, 0.04);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
.atelier-drop.is-error {
|
||||
border-color: #B91C1C;
|
||||
background: rgba(185, 28, 28, 0.03);
|
||||
}
|
||||
.atelier-drop.has-image {
|
||||
border-style: solid;
|
||||
border-color: rgba(34, 78, 47, 0.3);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.atelier-drop-eyebrow {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #A8A29E;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.atelier-drop-title {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-size: 18px;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #57534E;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.atelier-drop-hint {
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 11px;
|
||||
color: #A8A29E;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Status pill in corner of image */
|
||||
.atelier-status-pill {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px 5px 8px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(28, 25, 23, 0.08);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: #1C1917;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
z-index: 2;
|
||||
}
|
||||
.atelier-status-pill .atelier-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #16A34A;
|
||||
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
.atelier-status-pill.is-empty .atelier-dot {
|
||||
background: #D6D3D1;
|
||||
box-shadow: 0 0 0 2px rgba(214, 211, 209, 0.2);
|
||||
}
|
||||
.atelier-status-pill.is-empty {
|
||||
color: #A8A29E;
|
||||
}
|
||||
|
||||
/* Modal enter animation */
|
||||
@keyframes atelier-enter {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes atelier-backdrop {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes atelier-stagger {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.atelier-enter {
|
||||
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.atelier-backdrop {
|
||||
animation: atelier-backdrop 300ms ease both;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; }
|
||||
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; }
|
||||
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; }
|
||||
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; }
|
||||
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; }
|
||||
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; }
|
||||
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; }
|
||||
|
||||
/* Primary CTA — gradient with subtle inner highlight */
|
||||
.atelier-cta {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px 22px;
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%);
|
||||
background-size: 200% 100%;
|
||||
background-position: 0% 50%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 6px 18px -6px rgba(20, 83, 45, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-cta:hover {
|
||||
background-position: 100% 50%;
|
||||
box-shadow:
|
||||
0 10px 28px -8px rgba(20, 83, 45, 0.65),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.20),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.atelier-cta:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
.atelier-cta:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
background: #57534E;
|
||||
box-shadow: none;
|
||||
}
|
||||
.atelier-cta .atelier-cta-shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
|
||||
pointer-events: none;
|
||||
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.atelier-cta:hover .atelier-cta-shimmer {
|
||||
left: 130%;
|
||||
}
|
||||
|
||||
/* Discard button */
|
||||
.atelier-discard {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #786B53;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 8px 6px;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
.atelier-discard:hover {
|
||||
color: #1C1917;
|
||||
}
|
||||
|
||||
/* Brand selector */
|
||||
.atelier-select {
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1C1917;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
|
||||
border-radius: 0;
|
||||
padding: 10px 24px 10px 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23786B53' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right center;
|
||||
transition: border-color 220ms ease;
|
||||
}
|
||||
.atelier-select:hover { border-bottom-color: rgba(28, 25, 23, 0.28); }
|
||||
.atelier-select:focus { border-bottom-color: #224E2F; border-bottom-width: 2px; padding-bottom: 9.5px; }
|
||||
|
||||
/* Hint / help text */
|
||||
.atelier-hint {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #A8A29E;
|
||||
line-height: 1.4;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Error banner */
|
||||
.atelier-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, rgba(185, 28, 28, 0.06) 0%, rgba(185, 28, 28, 0.03) 100%);
|
||||
border: 1px solid rgba(185, 28, 28, 0.25);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 13px;
|
||||
color: #991B1B;
|
||||
}
|
||||
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
+23
-2
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
||||
@@ -6,6 +7,26 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-fraunces",
|
||||
axes: ["SOFT", "WONK", "opsz"],
|
||||
});
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-manrope",
|
||||
});
|
||||
|
||||
const fragmentMono = Fragment_Mono({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-fragment-mono",
|
||||
weight: "400",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
@@ -50,8 +71,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
|
||||
<body className="font-sans">
|
||||
<Providers>{children}</Providers>
|
||||
<ToastNotificationContainer />
|
||||
<CookieConsentBanner />
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
weight: ["400", "500", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// The Tuxedo layout's `template: "%s | Tuxedo Corn"` already appends the
|
||||
// brand — keep the bare title here so the rendered <title> is correct.
|
||||
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||
description:
|
||||
"Our 12-Ear Corn Box is packed with peak-season, super-sweet Olathe Sweet corn picked that morning and rushed to your door. Hand-selected, non-GMO, and 100% sweetness guaranteed.",
|
||||
keywords: [
|
||||
"sweet corn",
|
||||
"Olathe Sweet",
|
||||
"fresh corn box",
|
||||
"12 ears of corn",
|
||||
"farm fresh corn",
|
||||
"Tuxedo Corn",
|
||||
],
|
||||
openGraph: {
|
||||
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||
description:
|
||||
"Peak-season, super-sweet Olathe Sweet corn — picked that morning, rushed to your door. 100% sweetness guaranteed.",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1200&q=80",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Fresh sweet corn box — 12 ears of Olathe Sweet corn",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||
description:
|
||||
"Peak-season, super-sweet Olathe Sweet corn. Hand-selected, non-GMO, 100% sweetness guaranteed.",
|
||||
},
|
||||
};
|
||||
|
||||
const BRAND_SLUG = "tuxedo";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
logo_url?: string | null;
|
||||
logo_url_dark?: string | null;
|
||||
};
|
||||
|
||||
export default async function SweetCornBoxPage() {
|
||||
// Fetch the brand record so we have a real brand_id to thread through
|
||||
// the cart system. Falls back to a placeholder if Supabase is unreachable
|
||||
// (so the page still renders in dev / disconnected previews).
|
||||
let brandId = "00000000-0000-0000-0000-000000000000";
|
||||
let brandName = "Tuxedo Corn";
|
||||
let logoUrl: string | null = null;
|
||||
let logoUrlDark: string | null = null;
|
||||
|
||||
try {
|
||||
const [brandRes, settingsRes] = await Promise.all([
|
||||
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
|
||||
getBrandSettingsPublic(BRAND_SLUG),
|
||||
]);
|
||||
if (brandRes.data) {
|
||||
const b = brandRes.data as Brand;
|
||||
brandId = b.id;
|
||||
brandName = b.name;
|
||||
}
|
||||
if (settingsRes.success && settingsRes.settings) {
|
||||
const s = settingsRes.settings as Settings;
|
||||
logoUrl = s.logo_url ?? null;
|
||||
logoUrlDark = s.logo_url_dark ?? null;
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through with defaults
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${fraunces.variable} ${jetbrainsMono.variable} font-sans`}
|
||||
style={{
|
||||
// The product page uses editorial typography: a display serif
|
||||
// (Fraunces) for headlines and a mono (JetBrains Mono) for data
|
||||
// stamps. Body copy falls back to the global SF Pro stack.
|
||||
["--font-display" as never]: fraunces.style.fontFamily,
|
||||
["--font-mono" as never]: jetbrainsMono.style.fontFamily,
|
||||
}}
|
||||
>
|
||||
<SweetCornProductPage
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { createLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const 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)";
|
||||
};
|
||||
const 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";
|
||||
};
|
||||
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Park on west side. Use side entrance after 9am."
|
||||
rows={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: loading
|
||||
? "rgba(16, 185, 129, 0.4)"
|
||||
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
|
||||
boxShadow: loading
|
||||
? "none"
|
||||
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Creating…
|
||||
</span>
|
||||
) : (
|
||||
"Create Venue"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
@@ -21,12 +21,20 @@ type Props = {
|
||||
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 [state, setState] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset form when modal opens with optional duplicate source
|
||||
setCity(duplicateFrom?.city ?? "");
|
||||
setState(duplicateFrom?.state ?? "");
|
||||
setStateField(duplicateFrom?.state ?? "");
|
||||
setLocation(duplicateFrom?.location ?? "");
|
||||
setDate(duplicateFrom?.date ?? "");
|
||||
setTime(duplicateFrom?.time ?? "");
|
||||
@@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
||||
setStatus("draft");
|
||||
setError(null);
|
||||
// Auto-focus city field for fast data entry
|
||||
requestAnimationFrame(() => cityRef.current?.focus());
|
||||
}
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [isOpen, duplicateFrom]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!city.trim() || !state.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: state.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");
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
|
||||
|
||||
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 New Stop";
|
||||
const subtitle = isDuplicate && duplicateFrom
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
|
||||
const eyebrow = isDuplicate && duplicateFrom
|
||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||
: "Create a new tour stop for your route.";
|
||||
|
||||
const inputStyle = {
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
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)';
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
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';
|
||||
};
|
||||
: "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} subtitle={subtitle} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<GlassModal
|
||||
title={title}
|
||||
eyebrow={eyebrow}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-xl"
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{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
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
{/* 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
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
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}
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value.toUpperCase())}
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(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}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
|
||||
{/* 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
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market"
|
||||
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}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
|
||||
{/* 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
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(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}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
|
||||
<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
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(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}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
|
||||
{/* 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
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
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}
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
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}
|
||||
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
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(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}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
|
||||
</div>
|
||||
|
||||
{/* Status toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
|
||||
<div className="flex gap-3">
|
||||
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</label>
|
||||
<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="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Draft</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Active</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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={onClose}
|
||||
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"
|
||||
>
|
||||
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>
|
||||
) : (
|
||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
@@ -338,4 +404,4 @@ export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicat
|
||||
), [isOpen, close, brandId, duplicateFrom]);
|
||||
|
||||
return { open, close, Modal };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setActiveBrand } from "@/actions/set-active-brand";
|
||||
|
||||
export type BrandSelectorItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo_url: string | null;
|
||||
};
|
||||
|
||||
type BrandSelectorProps = {
|
||||
brands: BrandSelectorItem[];
|
||||
/** Currently-active brand id. `null` means "All brands" (platform_admin only). */
|
||||
activeBrandId: string | null;
|
||||
/** When true, shows the "All brands" pseudo-option at the top. */
|
||||
showAllBrandsOption: boolean;
|
||||
/** Whether the user has multi-brand access. Controls the "Multi-brand manager" badge. */
|
||||
isMultiBrandAdmin: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Brand selector dropdown.
|
||||
*
|
||||
* Renders a compact pill showing the active brand (or "All brands" for
|
||||
* platform_admin) and opens a list of accessible brands on click.
|
||||
*
|
||||
* On select: calls `setActiveBrand` server action then `router.refresh()`
|
||||
* so all server components re-read the new cookie and reload their data.
|
||||
* The URL is NOT changed — the cookie is the source of truth.
|
||||
*/
|
||||
export default function BrandSelector({
|
||||
brands,
|
||||
activeBrandId,
|
||||
showAllBrandsOption,
|
||||
isMultiBrandAdmin,
|
||||
}: BrandSelectorProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [open]);
|
||||
|
||||
// No brands + no "All brands" option = don't render
|
||||
if (!showAllBrandsOption && brands.length === 0) return null;
|
||||
if (!showAllBrandsOption && brands.length < 2) return null;
|
||||
|
||||
const activeBrand = brands.find((b) => b.id === activeBrandId);
|
||||
const label =
|
||||
showAllBrandsOption && !activeBrand
|
||||
? "All brands"
|
||||
: activeBrand?.name ?? "Select brand";
|
||||
|
||||
async function selectBrand(brandId: string | null) {
|
||||
setOpen(false);
|
||||
if (brandId === activeBrandId) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await setActiveBrand(brandId);
|
||||
if (res.success) {
|
||||
router.refresh();
|
||||
} else {
|
||||
// Keep UI usable if the server rejected; log for debugging
|
||||
console.error("setActiveBrand failed:", res.error);
|
||||
}
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={pending}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors text-left disabled:opacity-60"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||
color: "var(--admin-sidebar-text)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{showAllBrandsOption && !activeBrand ? "*" : label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||
{label}
|
||||
</span>
|
||||
{isMultiBrandAdmin && (
|
||||
<span
|
||||
className="text-[9px] font-medium px-1.5 py-0.5 rounded-md flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: "rgba(202, 117, 67, 0.18)",
|
||||
color: "var(--admin-accent)",
|
||||
}}
|
||||
title="Manages multiple brands"
|
||||
>
|
||||
multi
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-3 h-3 flex-shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-sidebar-bg)",
|
||||
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||
}}
|
||||
>
|
||||
{showAllBrandsOption && (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!activeBrand}
|
||||
onClick={() => selectBrand(null)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
<span className="flex-1">All brands</span>
|
||||
{!activeBrand && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{brands.map((b) => {
|
||||
const isActive = b.id === activeBrandId;
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => selectBrand(b.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{b.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
{isActive && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { updateLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
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("");
|
||||
|
||||
useEffect(() => {
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
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);
|
||||
}
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [location, isOpen]);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
const 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)";
|
||||
};
|
||||
const 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";
|
||||
};
|
||||
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
titleIcon?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
variant?: "default" | "elegant";
|
||||
};
|
||||
|
||||
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
const isElegant = variant === "elegant";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
>
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
|
||||
|
||||
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{titleIcon && (
|
||||
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
|
||||
{titleIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{isElegant ? (
|
||||
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
|
||||
{title}
|
||||
</h2>
|
||||
) : (
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
|
||||
>
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,22 @@ type Props = {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
/** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */
|
||||
compact?: boolean;
|
||||
/** Optional eyebrow text rendered above the title in compact mode. */
|
||||
eyebrow?: string;
|
||||
};
|
||||
|
||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
|
||||
export default function GlassModal({
|
||||
title,
|
||||
titleIcon,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-lg",
|
||||
compact = false,
|
||||
eyebrow,
|
||||
}: Props) {
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -33,25 +46,36 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
style={{
|
||||
backgroundColor: "rgba(60, 56, 37, 0.45)",
|
||||
backdropFilter: "blur(2px)",
|
||||
WebkitBackdropFilter: "blur(2px)",
|
||||
}}
|
||||
>
|
||||
{/* Modal card - solid white with shadow for high contrast */}
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col`}
|
||||
className={`relative w-full ${maxWidth} ${
|
||||
compact
|
||||
? "max-h-[min(640px,calc(100vh-2rem))]"
|
||||
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
|
||||
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
|
||||
>
|
||||
{/* Subtle top border accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
||||
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
|
||||
{!compact && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0"
|
||||
<div
|
||||
className={`flex items-center justify-between shrink-0 ${
|
||||
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
|
||||
}`}
|
||||
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
{titleIcon && (
|
||||
<div
|
||||
{titleIcon && !compact && (
|
||||
<div
|
||||
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||
>
|
||||
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
{compact && eyebrow && (
|
||||
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
|
||||
)}
|
||||
<h2
|
||||
className={`${
|
||||
compact
|
||||
? "ha-display text-lg truncate"
|
||||
: "text-lg sm:text-xl font-semibold truncate"
|
||||
} text-[var(--admin-text-primary)]`}
|
||||
style={compact ? undefined : { letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
|
||||
{subtitle && !compact && (
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2"
|
||||
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
|
||||
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
|
||||
compact
|
||||
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
|
||||
: "h-8 w-8 sm:h-9 sm:w-9"
|
||||
}`}
|
||||
style={
|
||||
compact
|
||||
? undefined
|
||||
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
|
||||
}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -81,8 +123,12 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - scrollable */}
|
||||
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
|
||||
{/* Content */}
|
||||
<div
|
||||
className={`relative overflow-y-auto flex-1 ${
|
||||
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useTransition, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deleteLocation } from "@/actions/locations";
|
||||
import AddLocationModal from "@/components/admin/AddLocationModal";
|
||||
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
|
||||
import {
|
||||
AdminSearchInput,
|
||||
AdminFilterTabs,
|
||||
AdminButton,
|
||||
AdminIconButton,
|
||||
useToast,
|
||||
Skeleton,
|
||||
} from "@/components/admin/design-system";
|
||||
|
||||
export type AdminLocation = {
|
||||
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;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
slug: string | null;
|
||||
stop_count: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
locations: AdminLocation[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
type StatusFilter = "all" | "active" | "inactive";
|
||||
|
||||
export default function LocationsTab({ locations, brandId }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editing, setEditing] = useState<LocationForEdit | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const t = setTimeout(() => setIsLoading(false), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [page, statusFilter, search]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: locations.length,
|
||||
active: locations.filter((l) => l.active).length,
|
||||
inactive: locations.filter((l) => !l.active).length,
|
||||
}),
|
||||
[locations]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return locations.filter((l) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
l.name.toLowerCase().includes(q) ||
|
||||
(l.address ?? "").toLowerCase().includes(q) ||
|
||||
(l.city ?? "").toLowerCase().includes(q) ||
|
||||
(l.contact_name ?? "").toLowerCase().includes(q);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && l.active) ||
|
||||
(statusFilter === "inactive" && !l.active);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [locations, search, statusFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
const tabs = [
|
||||
{ value: "all", label: "All", count: counts.all },
|
||||
{ value: "active", label: "Active", count: counts.active },
|
||||
{ value: "inactive", label: "Inactive", count: counts.inactive },
|
||||
];
|
||||
|
||||
function handleAdded() {
|
||||
setShowAdd(false);
|
||||
showSuccess("Venue created");
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
function handleEdited() {
|
||||
setEditing(null);
|
||||
showSuccess("Venue updated");
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
async function handleDelete(loc: AdminLocation) {
|
||||
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
|
||||
setPendingDeleteId(loc.id);
|
||||
setDeleteError(null);
|
||||
const res = await deleteLocation(loc.id, brandId);
|
||||
setPendingDeleteId(null);
|
||||
if (res.success) {
|
||||
showSuccess("Venue deleted");
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
setDeleteError(res.error ?? "Delete failed");
|
||||
showError("Delete failed", res.error ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
|
||||
<AdminSearchInput
|
||||
placeholder="Search venues by name, city, or contact…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
onClear={() => {
|
||||
setSearch("");
|
||||
setPage(0);
|
||||
}}
|
||||
showClear
|
||||
className="flex-1 min-w-48 max-w-72"
|
||||
/>
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => {
|
||||
setStatusFilter(value as StatusFilter);
|
||||
setPage(0);
|
||||
}}
|
||||
tabs={tabs}
|
||||
size="sm"
|
||||
showCounts
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
|
||||
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
icon={
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Add Venue
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteError && (
|
||||
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
|
||||
{deleteError}{" "}
|
||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">Venue</th>
|
||||
<th className="px-5 py-4 font-semibold">Address</th>
|
||||
<th className="px-5 py-4 font-semibold">Contact</th>
|
||||
<th className="px-5 py-4 font-semibold text-center">Stops</th>
|
||||
<th className="px-5 py-4 font-semibold">Status</th>
|
||||
<th className="px-5 py-4 font-semibold" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
|
||||
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<p className="text-sm">
|
||||
{search || statusFilter !== "all"
|
||||
? "No venues match your filters."
|
||||
: "No venues yet. Add your first venue to get started."}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginated.map((loc) => (
|
||||
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
|
||||
{(loc.city || loc.state) && (
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">
|
||||
{[loc.city, loc.state].filter(Boolean).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{loc.address ? (
|
||||
<div>
|
||||
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
|
||||
{loc.zip && (
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[var(--admin-text-muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{loc.contact_name || loc.phone || loc.contact_email ? (
|
||||
<div className="space-y-0.5">
|
||||
{loc.contact_name && (
|
||||
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
|
||||
)}
|
||||
{loc.phone && (
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
|
||||
)}
|
||||
{loc.contact_email && (
|
||||
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
|
||||
{loc.contact_email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[var(--admin-text-muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
{loc.stop_count > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
style={{
|
||||
background: "rgba(16, 185, 129, 0.12)",
|
||||
color: "#047857",
|
||||
}}
|
||||
>
|
||||
{loc.stop_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{loc.active ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditing(loc as LocationForEdit)}
|
||||
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
title="Edit venue"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(loc)}
|
||||
disabled={pendingDeleteId === loc.id}
|
||||
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||
title="Delete venue"
|
||||
>
|
||||
{pendingDeleteId === loc.id ? (
|
||||
<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>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showAdd && (
|
||||
<AddLocationModal
|
||||
isOpen={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
brandId={brandId}
|
||||
onSuccess={handleAdded}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditLocationModal
|
||||
isOpen={!!editing}
|
||||
onClose={() => setEditing(null)}
|
||||
location={editing}
|
||||
brandId={brandId}
|
||||
onSuccess={handleEdited}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type ProductFormModalProps = {
|
||||
open: boolean;
|
||||
mode: "add" | "edit";
|
||||
onClose: () => void;
|
||||
onSubmit: (values: ProductFormValues) => Promise<{ success: boolean; error?: string }>;
|
||||
/** Defaults / initial values (edit mode passes the current product) */
|
||||
initial?: Partial<ProductFormValues>;
|
||||
/** List of selectable brands (only shown to platform admins) */
|
||||
brands?: { id: string; name: string }[];
|
||||
/** When true the brand picker is hidden and the admin's brand is used */
|
||||
lockBrand?: boolean;
|
||||
/** Hard-locked brand id (for brand_admin / store_employee) */
|
||||
lockedBrandId?: string;
|
||||
/** Pre-uploaded image URL (edit mode) */
|
||||
initialImageUrl?: string | null;
|
||||
/** Server action: upload a file and return the public URL */
|
||||
onUploadImage: (file: File) => Promise<{ success: boolean; imageUrl?: string; error?: string }>;
|
||||
};
|
||||
|
||||
export type ProductFormValues = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
type: "pickup" | "wholesale" | "subscription";
|
||||
brand_id: string;
|
||||
active: boolean;
|
||||
is_taxable: boolean;
|
||||
image_url: string | null;
|
||||
available_from?: string | null;
|
||||
available_until?: string | null;
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS: Array<{
|
||||
value: ProductFormValues["type"];
|
||||
label: string;
|
||||
italic: string;
|
||||
icon: React.ReactNode;
|
||||
}> = [
|
||||
{
|
||||
value: "pickup",
|
||||
label: "Pickup",
|
||||
italic: "Farm & stops",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 9l1.5-4h15L21 9" />
|
||||
<path d="M3 9v11a1 1 0 001 1h16a1 1 0 001-1V9" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 14h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "wholesale",
|
||||
label: "Wholesale",
|
||||
italic: "B2B portal",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7h18l-2 12H5L3 7z" />
|
||||
<path d="M8 7V5a4 4 0 018 0v2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "subscription",
|
||||
label: "Subscription",
|
||||
italic: "Recurring",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 11-3-6.7" />
|
||||
<path d="M21 4v5h-5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductFormModal({
|
||||
open,
|
||||
mode,
|
||||
onClose,
|
||||
onSubmit,
|
||||
initial,
|
||||
brands = [],
|
||||
lockBrand = false,
|
||||
lockedBrandId = "",
|
||||
initialImageUrl = null,
|
||||
onUploadImage,
|
||||
}: ProductFormModalProps) {
|
||||
// Form state
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [price, setPrice] = useState(initial?.price ?? "");
|
||||
const [type, setType] = useState<ProductFormValues["type"]>(initial?.type ?? "pickup");
|
||||
const [brandId, setBrandId] = useState(
|
||||
initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? ""
|
||||
);
|
||||
const [active, setActive] = useState(initial?.active ?? true);
|
||||
const [isTaxable, setIsTaxable] = useState(initial?.is_taxable ?? true);
|
||||
const [availableFrom, setAvailableFrom] = useState(initial?.available_from ? initial.available_from.split('T')[0] : "");
|
||||
const [availableUntil, setAvailableUntil] = useState(initial?.available_until ? initial.available_until.split('T')[0] : "");
|
||||
|
||||
// Image state
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(initialImageUrl);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialImageUrl);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDrag, setIsDrag] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Submit state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Reset state when opening with new initial values
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? "");
|
||||
setDescription(initial?.description ?? "");
|
||||
setPrice(initial?.price ?? "");
|
||||
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
|
||||
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
|
||||
setActive(initial?.active ?? true);
|
||||
setIsTaxable(initial?.is_taxable ?? true);
|
||||
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
|
||||
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
|
||||
setImagePreview(initialImageUrl);
|
||||
setImageUrl(initialImageUrl);
|
||||
setUploading(false);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setIsDrag(false);
|
||||
}, [open, initial, initialImageUrl, lockedBrandId, brands]);
|
||||
|
||||
// Body scroll lock + escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("PNG, JPEG, or WebP only.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("File must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
|
||||
// Local preview first
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
setImagePreview(localUrl);
|
||||
|
||||
const result = await onUploadImage(file);
|
||||
setUploading(false);
|
||||
|
||||
if (result.success && result.imageUrl) {
|
||||
setImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
setImagePreview(imageUrl); // revert
|
||||
}
|
||||
},
|
||||
[onUploadImage, imageUrl]
|
||||
);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Product name is required.");
|
||||
return;
|
||||
}
|
||||
if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
|
||||
setError("Valid price is required.");
|
||||
return;
|
||||
}
|
||||
if (!lockBrand && !brandId) {
|
||||
setError("Please select a brand.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const res = await onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
price,
|
||||
type,
|
||||
brand_id: lockBrand ? lockedBrandId : brandId,
|
||||
active,
|
||||
is_taxable: isTaxable,
|
||||
image_url: imageUrl,
|
||||
available_from: availableFrom ? new Date(availableFrom).toISOString() : null,
|
||||
available_until: availableUntil ? new Date(availableUntil).toISOString() : null,
|
||||
});
|
||||
setSaving(false);
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
const showBrandPicker = !lockBrand;
|
||||
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "rgba(28, 25, 23, 0.55)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
||||
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60"
|
||||
>
|
||||
{/* grain overlay */}
|
||||
<div className="atelier-grain" aria-hidden />
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="relative shrink-0 px-6 sm:px-10 pt-6 sm:pt-8 pb-5 sm:pb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2.5 mb-3">
|
||||
<span className="atelier-section-num">
|
||||
{mode === "add" ? "Nouveau" : "Mise à jour"} · MMXXVI
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="atelier-title text-3xl sm:text-4xl md:text-5xl">
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
Add a <span className="atelier-italic">product</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Edit <span className="atelier-italic">{initial?.name || "product"}</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<p className="atelier-italic mt-2 text-sm sm:text-[15px] max-w-md leading-relaxed">
|
||||
{mode === "add"
|
||||
? "Compose a new entry for the harvest catalog — every detail, like a recipe."
|
||||
: "Refine the details of this entry. The catalog is a living record."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 flex h-9 w-9 items-center justify-center rounded-full transition-all duration-200 hover:bg-stone-900 hover:text-white text-stone-500"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative shrink-0 px-6 sm:px-10">
|
||||
<hr className="atelier-rule" />
|
||||
</div>
|
||||
|
||||
{/* BODY — two columns */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="relative flex-1 overflow-y-auto"
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDrag(true);
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-0">
|
||||
{/* LEFT — Image hero */}
|
||||
<div className="relative px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:border-r border-stone-200/60 lg:pr-8">
|
||||
<div className="atelier-section-num mb-4">01 · Media</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDrag(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget === e.target) setIsDrag(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDrag(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={[
|
||||
"atelier-drop",
|
||||
isDrag ? "is-drag" : "",
|
||||
uploadError ? "is-error" : "",
|
||||
imagePreview ? "has-image" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* status pill */}
|
||||
<div
|
||||
className={[
|
||||
"atelier-status-pill",
|
||||
imagePreview ? "" : "is-empty",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="atelier-dot" />
|
||||
{imagePreview
|
||||
? uploading
|
||||
? "Uploading"
|
||||
: "Image set"
|
||||
: "No image yet"}
|
||||
</div>
|
||||
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" />
|
||||
<p className="atelier-drop-title">Pouring into the cellar…</p>
|
||||
</div>
|
||||
) : imagePreview ? (
|
||||
<div className="absolute inset-0 p-3 sm:p-4">
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Product preview"
|
||||
fill
|
||||
style={{ objectFit: "contain" }}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImagePreview(null);
|
||||
setImageUrl(null);
|
||||
}}
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 rounded-full bg-stone-900/85 backdrop-blur-sm px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-white hover:bg-stone-900 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ornamental drop zone content */}
|
||||
<svg
|
||||
className="h-10 w-10 text-[#224E2F]/70"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 3v13" />
|
||||
<path d="M7 8l5-5 5 5" />
|
||||
<path d="M4 16v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
|
||||
<circle cx="12" cy="20" r="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
<p className="atelier-drop-eyebrow">Drag & drop</p>
|
||||
<p className="atelier-drop-title">
|
||||
or click to choose<br />a harvest portrait
|
||||
</p>
|
||||
<p className="atelier-drop-hint">PNG · JPEG · WebP · 5MB max</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}>
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Form fields */}
|
||||
<div className="px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:pl-10 space-y-7 atelier-stagger">
|
||||
{error && (
|
||||
<div className="atelier-error">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 02 IDENTITY */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<div className="atelier-section-num">02 · Identity</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="atelier-name"
|
||||
className="atelier-section-label block mb-1"
|
||||
>
|
||||
Product name
|
||||
</label>
|
||||
<input
|
||||
id="atelier-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Dozen Sweet Corn"
|
||||
className="atelier-input atelier-input--display"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label
|
||||
htmlFor="atelier-desc"
|
||||
className="atelier-section-label block mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="atelier-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A note from the field — origin, variety, picking notes…"
|
||||
rows={2}
|
||||
className="atelier-input resize-none"
|
||||
style={{ minHeight: 56 }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 03 COMMERCE */}
|
||||
<section>
|
||||
<div className="atelier-section-num mb-4">03 · Commerce</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="atelier-price"
|
||||
className="atelier-section-label block mb-1"
|
||||
>
|
||||
Price
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="atelier-price-sigil">$</span>
|
||||
<input
|
||||
id="atelier-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="atelier-price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBrandPicker ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="atelier-brand"
|
||||
className="atelier-section-label block mb-1"
|
||||
>
|
||||
Brand
|
||||
</label>
|
||||
<select
|
||||
id="atelier-brand"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
className="atelier-select"
|
||||
>
|
||||
{brands.length === 0 && <option value="">Loading brands…</option>}
|
||||
{brands.length > 0 && <option value="">Select a brand…</option>}
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="atelier-hint">
|
||||
You administer multiple brands — choose the one this product belongs to.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="atelier-section-label block mb-1">Brand</span>
|
||||
<div className="py-2.5 border-b border-stone-300/40">
|
||||
<span className="font-[family-name:var(--font-fraunces)] text-[15px] text-stone-700">
|
||||
{lockedBrandName || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<span className="atelier-section-label block mb-2">Type</span>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setType(opt.value)}
|
||||
className={[
|
||||
"atelier-type-card",
|
||||
type === opt.value ? "is-selected" : "",
|
||||
].join(" ")}
|
||||
aria-pressed={type === opt.value}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="atelier-type-icon">{opt.icon}</span>
|
||||
<svg
|
||||
className="atelier-type-check h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="atelier-type-name">{opt.label}</div>
|
||||
<div
|
||||
className={[
|
||||
"text-[10px] font-[family-name:var(--font-fragment-mono)] uppercase tracking-wider mt-0.5",
|
||||
type === opt.value ? "text-white/60" : "text-stone-500",
|
||||
].join(" ")}
|
||||
>
|
||||
{opt.italic}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 04 SETTINGS */}
|
||||
<section>
|
||||
<div className="atelier-section-num mb-4">04 · Settings</div>
|
||||
<div className="flex flex-wrap items-center gap-6 sm:gap-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive((v) => !v)}
|
||||
className={["atelier-toggle", active ? "is-on" : ""].join(" ")}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="atelier-toggle-track">
|
||||
<span className="atelier-toggle-thumb" />
|
||||
</span>
|
||||
<span className="atelier-toggle-label">Active</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsTaxable((v) => !v)}
|
||||
className={["atelier-toggle is-gold", isTaxable ? "is-on" : ""].join(" ")}
|
||||
aria-pressed={isTaxable}
|
||||
>
|
||||
<span className="atelier-toggle-track">
|
||||
<span className="atelier-toggle-thumb" />
|
||||
</span>
|
||||
<span className="atelier-toggle-label">Taxable</span>
|
||||
</button>
|
||||
|
||||
<p className="atelier-hint flex-1 min-w-[200px]">
|
||||
Active products appear in the storefront. Taxable items add sales tax at checkout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Seasonal Availability */}
|
||||
<div className="mt-6 pt-6 border-t border-stone-200/40">
|
||||
<span className="atelier-section-label block mb-3">Seasonal Availability</span>
|
||||
<p className="atelier-hint mb-4">Set when this product is available (e.g., mid-July through mid-September)</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="atelier-available-from" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">Start Date</label>
|
||||
<input
|
||||
id="atelier-available-from"
|
||||
type="date"
|
||||
value={availableFrom}
|
||||
onChange={(e) => setAvailableFrom(e.target.value)}
|
||||
className="atelier-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="atelier-available-until" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">End Date</label>
|
||||
<input
|
||||
id="atelier-available-until"
|
||||
type="date"
|
||||
value={availableUntil}
|
||||
onChange={(e) => setAvailableUntil(e.target.value)}
|
||||
className="atelier-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(availableFrom || availableUntil) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAvailableFrom(""); setAvailableUntil(""); }}
|
||||
className="mt-2 text-[11px] font-mono uppercase tracking-wider text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
Clear dates
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-stone-200/60 bg-[#FAF7F0]/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="atelier-discard"
|
||||
>
|
||||
discard changes
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* readiness indicator */}
|
||||
<div className="hidden sm:flex items-center gap-2 text-[11px] font-mono uppercase tracking-wider text-stone-500">
|
||||
<span
|
||||
className={[
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
name.trim() && price
|
||||
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]"
|
||||
: "bg-stone-300",
|
||||
].join(" ")}
|
||||
/>
|
||||
{name.trim() && price ? "Ready" : "Required fields pending"}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
form="atelier-product-form"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || uploading || !name.trim() || !price}
|
||||
className="atelier-cta"
|
||||
>
|
||||
<span className="atelier-cta-shimmer" />
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="h-3.5 w-3.5 rounded-full border-2 border-white/40 border-t-white animate-spin" />
|
||||
{mode === "add" ? "Composing…" : "Saving…"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mode === "add" ? "Add to catalog" : "Save changes"}
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useTransition } from "react";
|
||||
import { useState, useCallback, useTransition, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
import { updateProduct } from "@/actions/products/update-product";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
|
||||
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
|
||||
|
||||
type Product = {
|
||||
@@ -20,16 +21,8 @@ type Product = {
|
||||
image_url?: string | null;
|
||||
brand_id: string;
|
||||
is_taxable: boolean;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url: string;
|
||||
is_taxable: boolean;
|
||||
available_from?: string | null;
|
||||
available_until?: string | null;
|
||||
};
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
@@ -74,7 +67,17 @@ const PackageIconHeader = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId?: string }) {
|
||||
export default function ProductsClient({
|
||||
products,
|
||||
brandId,
|
||||
brands = [],
|
||||
isPlatformAdmin = false,
|
||||
}: {
|
||||
products: Product[];
|
||||
brandId?: string;
|
||||
brands?: { id: string; name: string }[];
|
||||
isPlatformAdmin?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -83,28 +86,10 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Image upload states
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
@@ -147,155 +132,104 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
const handleUploadImage = useCallback(
|
||||
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: "Image must be under 5MB." };
|
||||
}
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||
|
||||
setUploading(false);
|
||||
|
||||
if (result.success) {
|
||||
setPendingImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
setFormData({ ...formData, image_url: result.imageUrl });
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
}
|
||||
}
|
||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||
if (result.success) {
|
||||
return { success: true, imageUrl: result.imageUrl };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
[editingProduct]
|
||||
);
|
||||
|
||||
const openAddModal = useCallback(() => {
|
||||
setEditingProduct(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const openEditModal = useCallback((product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setFormData({
|
||||
name: product.name,
|
||||
description: product.description || "",
|
||||
price: String(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
image_url: product.image_url || "",
|
||||
is_taxable: product.is_taxable,
|
||||
});
|
||||
setImagePreview(product.image_url || null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setEditingProduct(null);
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
setIsLoading(true);
|
||||
const handleModalSubmit = useCallback(
|
||||
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const price = parseFloat(values.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
return { success: false, error: "Please enter a valid price" };
|
||||
}
|
||||
if (!values.name.trim()) {
|
||||
return { success: false, error: "Product name is required" };
|
||||
}
|
||||
|
||||
const price = parseFloat(formData.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
setError("Please enter a valid price");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Platform admins pick a brand in the modal; everyone else is locked.
|
||||
const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
|
||||
if (!effectiveBrandId) {
|
||||
return { success: false, error: "Please choose a brand for this product." };
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError("Product name is required");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, effectiveBrandId, {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
price,
|
||||
type: values.type,
|
||||
active: values.active,
|
||||
image_url: values.image_url,
|
||||
is_taxable: values.is_taxable,
|
||||
});
|
||||
} else {
|
||||
result = await createProduct(effectiveBrandId, {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
price,
|
||||
type: values.type,
|
||||
active: values.active,
|
||||
image_url: values.image_url,
|
||||
is_taxable: values.is_taxable,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!brandId) {
|
||||
setError("Brand ID is required");
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
showError("Failed to save product", result.error ?? "Please try again");
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
editingProduct ? "Product updated" : "Product created",
|
||||
`${values.name} has been saved`
|
||||
);
|
||||
startTransition(() => router.refresh());
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Network error. Please try again." };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
const imageUrl = pendingImageUrl || formData.image_url || null;
|
||||
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
} else {
|
||||
result = await createProduct(brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save product");
|
||||
showError("Failed to save product", result.error ?? "Please try again");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess(editingProduct ? "Product updated" : "Product created", `${formData.name} has been saved`);
|
||||
closeModal();
|
||||
setIsLoading(false);
|
||||
startTransition(() => router.refresh());
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
showError("Network error", "Please check your connection and try again");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
|
||||
);
|
||||
|
||||
const handleDelete = async (productId: string) => {
|
||||
setDeletingId(productId);
|
||||
@@ -399,203 +333,38 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<GlassModal
|
||||
title={editingProduct ? "Edit Product" : "Add Product"}
|
||||
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Product name"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-colors ${
|
||||
error && !formData.name.trim()
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Product description"
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Price <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className={`w-full rounded-xl border pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
error && (!formData.price || parseFloat(formData.price) < 0)
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="pickup">Pickup</option>
|
||||
<option value="wholesale">Wholesale</option>
|
||||
<option value="subscription">Subscription</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-stone-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<div className="relative">
|
||||
<div className="relative h-32 sm:h-40 w-auto">
|
||||
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</div>
|
||||
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Icons.upload("h-8 w-8 text-stone-400")}
|
||||
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
||||
)}
|
||||
|
||||
{(imagePreview || formData.image_url) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setFormData({ ...formData, image_url: "" });
|
||||
}}
|
||||
className="mt-2 text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_taxable}
|
||||
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Taxable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={saving}
|
||||
disabled={!formData.name.trim() || !formData.price}
|
||||
>
|
||||
{editingProduct ? "Save Changes" : "Create Product"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
)}
|
||||
{/* Modal — Atelier des Récoltes (editorial product form) */}
|
||||
<ProductFormModal
|
||||
open={showModal}
|
||||
mode={editingProduct ? "edit" : "add"}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleModalSubmit}
|
||||
onUploadImage={handleUploadImage}
|
||||
brands={brands}
|
||||
lockBrand={!isPlatformAdmin}
|
||||
lockedBrandId={brandId ?? ""}
|
||||
initial={
|
||||
editingProduct
|
||||
? {
|
||||
name: editingProduct.name,
|
||||
description: editingProduct.description || "",
|
||||
price: String(editingProduct.price),
|
||||
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
|
||||
brand_id: editingProduct.brand_id,
|
||||
active: editingProduct.active,
|
||||
is_taxable: editingProduct.is_taxable,
|
||||
available_from: editingProduct.available_from ?? null,
|
||||
available_until: editingProduct.available_until ?? null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
initialImageUrl={editingProduct?.image_url ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
@@ -615,8 +384,51 @@ function TableView({
|
||||
onDeleteCancel: () => void;
|
||||
deletingId: string | null;
|
||||
}) {
|
||||
// Track the position of the open row's three-dots button so the popup can be
|
||||
// rendered via portal at body level (escapes any overflow:hidden ancestors
|
||||
// and any table-row stacking-context quirks).
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteConfirm) {
|
||||
setMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const btn = buttonRefs.current[deleteConfirm];
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
}
|
||||
}, [deleteConfirm]);
|
||||
|
||||
// Reposition on scroll/resize so the popup stays anchored to its button.
|
||||
useEffect(() => {
|
||||
if (!deleteConfirm) return;
|
||||
const updatePos = () => {
|
||||
const btn = buttonRefs.current[deleteConfirm];
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", updatePos, true);
|
||||
window.addEventListener("resize", updatePos);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updatePos, true);
|
||||
window.removeEventListener("resize", updatePos);
|
||||
};
|
||||
}, [deleteConfirm]);
|
||||
|
||||
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
@@ -700,44 +512,15 @@ function TableView({
|
||||
Edit
|
||||
</AdminButton>
|
||||
<button
|
||||
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
aria-label="Product actions"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{deleteConfirm === product.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the product. If attached to orders, it will be hidden.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<AdminButton
|
||||
onClick={() => onDeleteConfirm(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -745,6 +528,50 @@ function TableView({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Delete confirmation popup — rendered via portal at body level with
|
||||
position: fixed so it sits above all table rows and escapes the
|
||||
overflow-visible container. */}
|
||||
{mounted && deleteConfirm && openProduct && menuPos &&
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={onDeleteCancel}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Confirm delete"
|
||||
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
|
||||
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{openProduct.name}"?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the product. If attached to orders, it will be hidden.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<AdminButton
|
||||
onClick={() => onDeleteConfirm(openProduct.id)}
|
||||
disabled={deletingId === openProduct.id}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{deletingId === openProduct.id ? "..." : "Delete"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
type Stat = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
emphasis?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stats: Stat[];
|
||||
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
|
||||
right?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function StatsStrip({ stats, right }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
{stats.map((s, i) => (
|
||||
<span key={i} className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
|
||||
>
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{right && <div className="ml-auto">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
import StopEditForm from "@/components/admin/StopEditForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
|
||||
import { getStopDetails } from "@/actions/stops/get-stop-details";
|
||||
import { useToast } from "@/components/admin/design-system";
|
||||
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
|
||||
|
||||
type Tab = "details" | "products" | "message";
|
||||
|
||||
type Props = {
|
||||
stopId: string;
|
||||
/** Optional: when the user clicks Duplicate from inside the modal. */
|
||||
onDuplicate?: (stopId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [stop, setStop] = useState<StopDetail | null>(null);
|
||||
const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]);
|
||||
const [assignedProducts, setAssignedProducts] = useState<AssignedProduct[]>([]);
|
||||
const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]);
|
||||
const [callerUid, setCallerUid] = useState<string>("");
|
||||
const [tab, setTab] = useState<Tab>("details");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
getStopDetails(stopId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.success) {
|
||||
setLoadError(res.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setStop(res.stop);
|
||||
setAllProducts(res.allProducts);
|
||||
setAssignedProducts(res.assignedProducts);
|
||||
setBrands(res.brands);
|
||||
setCallerUid(res.callerUid);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setLoadError(err?.message ?? "Failed to load stop");
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [stopId]);
|
||||
|
||||
function refresh() {
|
||||
getStopDetails(stopId).then((res) => {
|
||||
if (!res.success) return;
|
||||
setStop(res.stop);
|
||||
setAllProducts(res.allProducts);
|
||||
setAssignedProducts(res.assignedProducts);
|
||||
setBrands(res.brands);
|
||||
setCallerUid(res.callerUid);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditSaved() {
|
||||
refresh();
|
||||
showSuccess("Stop updated", "Changes have been saved");
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
const subtitle = stop?.brands
|
||||
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title={loading ? "Loading…" : stop ? `${stop.city}, ${stop.state}` : "Stop"}
|
||||
subtitle={subtitle ?? "Stop details"}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-3xl"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" />
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{loadError}
|
||||
</div>
|
||||
) : stop ? (
|
||||
<div className="space-y-5">
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1"
|
||||
role="tablist"
|
||||
>
|
||||
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
|
||||
Details
|
||||
</TabButton>
|
||||
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Products
|
||||
{assignedProducts.length > 0 && (
|
||||
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
|
||||
{assignedProducts.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TabButton>
|
||||
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
|
||||
Message
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === "details" && (
|
||||
<DetailsPanel
|
||||
stop={stop}
|
||||
brands={brands}
|
||||
onDuplicate={onDuplicate}
|
||||
onSaved={handleEditSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "products" && (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
Assigned Products
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
Manage which products are available at this stop.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<StopProductAssignment
|
||||
stopId={stop.id}
|
||||
allProducts={allProducts}
|
||||
assignedProducts={assignedProducts}
|
||||
callerUid={callerUid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "message" && (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
Message Customers
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
Send updates to customers with pending pickups at this stop.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
|
||||
active
|
||||
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsPanel({
|
||||
stop,
|
||||
brands,
|
||||
onDuplicate,
|
||||
onSaved,
|
||||
}: {
|
||||
stop: StopDetail;
|
||||
brands: { id: string; name: string; slug: string }[];
|
||||
onDuplicate?: (stopId: string) => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
|
||||
stop.active
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-stone-200 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
|
||||
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
|
||||
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
|
||||
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
|
||||
{stop.address}
|
||||
{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{stop.cutoff_time && (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
|
||||
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
|
||||
{new Date(stop.cutoff_time).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{onDuplicate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDuplicate(stop.id)}
|
||||
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||
>
|
||||
Duplicate Stop
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||
>
|
||||
Duplicate Stop
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
Update stop details, location, and availability.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<StopEditForm
|
||||
stop={{
|
||||
id: stop.id,
|
||||
city: stop.city,
|
||||
state: stop.state,
|
||||
date: stop.date,
|
||||
time: stop.time,
|
||||
location: stop.location,
|
||||
slug: stop.slug,
|
||||
active: stop.active,
|
||||
brand_id: stop.brand_id,
|
||||
address: stop.address,
|
||||
zip: stop.zip,
|
||||
cutoff_time: stop.cutoff_time,
|
||||
}}
|
||||
brands={brands}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,11 @@ type StopEditFormProps = {
|
||||
cutoff_time?: string | null;
|
||||
};
|
||||
brands: Brand[];
|
||||
/** Optional callback fired after a successful save. */
|
||||
onSaved?: () => void;
|
||||
};
|
||||
|
||||
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
showSuccess("Stop updated", `${city}, ${state} has been saved`);
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
onSaved?.();
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import React, { useState, useTransition, useEffect } from "react";
|
||||
import React, { useState, useTransition, useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system";
|
||||
import {
|
||||
AdminSearchInput,
|
||||
AdminFilterTabs,
|
||||
AdminButton,
|
||||
AdminIconButton,
|
||||
useToast,
|
||||
Skeleton,
|
||||
} from "@/components/admin/design-system";
|
||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||
import AddStopModal from "@/components/admin/AddStopModal";
|
||||
import StopDetailModal from "@/components/admin/StopDetailModal";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
@@ -18,14 +28,27 @@ type Stop = {
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
address?: string | null;
|
||||
brands: { name: string } | { name: string }[] | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
/**
|
||||
* Hide the internal search/filter bar at the top of the table. Use when
|
||||
* the parent component already renders shared filters (e.g. StopsViewClient).
|
||||
*/
|
||||
hideInternalFilterBar?: boolean;
|
||||
};
|
||||
|
||||
export default function StopTableClient({ stops }: Props) {
|
||||
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
||||
all: "all",
|
||||
active: "active",
|
||||
inactive: "inactive",
|
||||
draft: "draft",
|
||||
};
|
||||
|
||||
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -36,57 +59,66 @@ export default function StopTableClient({ stops }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [openStopId, setOpenStopId] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Simulate loading when filters change
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
||||
const timer = setTimeout(() => setIsLoading(false), 220);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, statusFilter, search]);
|
||||
|
||||
const filtered = stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
s.city.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.location.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||
(statusFilter === "draft" && s.status === "draft");
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||
(statusFilter === "draft" && s.status === "draft");
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const statusCounts = {
|
||||
all: stops.length,
|
||||
active: stops.filter(s => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter(s => s.status === "draft").length,
|
||||
};
|
||||
const statusCounts = useMemo(
|
||||
() => ({
|
||||
all: stops.length,
|
||||
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter((s) => s.status === "draft").length,
|
||||
}),
|
||||
[stops]
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ value: "all", label: "All", count: statusCounts.all },
|
||||
{ value: "active", label: "Active", count: statusCounts.active },
|
||||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||||
];
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: "All", count: statusCounts.all },
|
||||
{ value: "active", label: "Active", count: statusCounts.active },
|
||||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||||
],
|
||||
[statusCounts]
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const draftStops = stops.filter(s => s.status === "draft" && s.active);
|
||||
|
||||
// Bulk selection
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
setSelectedStops(new Set());
|
||||
} else {
|
||||
setSelectedStops(new Set(paginatedStops.map(s => s.id)));
|
||||
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStopSelection = (stopId: string) => {
|
||||
setSelectedStops(prev => {
|
||||
setSelectedStops((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stopId)) {
|
||||
next.delete(stopId);
|
||||
@@ -99,13 +131,11 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
async function handleBulkPublish() {
|
||||
if (selectedStops.size === 0) return;
|
||||
|
||||
setBulkPublishing(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const stopId of selectedStops) {
|
||||
const stop = stops.find(s => s.id === stopId);
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (stop && stop.status === "draft") {
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
if (result.success) {
|
||||
@@ -115,83 +145,125 @@ export default function StopTableClient({ stops }: Props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBulkPublishing(false);
|
||||
setSelectedStops(new Set());
|
||||
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`);
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
||||
} else {
|
||||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
setDeleteError(null);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
|
||||
{!hideInternalFilterBar && (
|
||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||
<AdminSearchInput
|
||||
placeholder="Search stops..."
|
||||
placeholder="Search by city or venue…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
onClear={() => { setSearch(""); setPage(0); }}
|
||||
showClear={true}
|
||||
className="flex-1 min-w-48 max-w-64"
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
onClear={() => {
|
||||
setSearch("");
|
||||
setPage(0);
|
||||
}}
|
||||
showClear
|
||||
className="flex-1 min-w-[180px] max-w-[280px]"
|
||||
/>
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }}
|
||||
onTabChange={(value) => {
|
||||
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
|
||||
setPage(0);
|
||||
}}
|
||||
tabs={tabs}
|
||||
size="sm"
|
||||
showCounts={true}
|
||||
showCounts
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
|
||||
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowImport(true)}
|
||||
icon={
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Upload Schedule
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
icon={
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Add Stop
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedStops.size > 0 && (
|
||||
<div className="mx-5 my-3 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
||||
<div className="mx-4 my-3 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
|
||||
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedStops(new Set())}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
||||
className="text-xs text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -209,7 +281,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteError && (
|
||||
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
|
||||
<div className="mx-4 my-3 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
{deleteError}{" "}
|
||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||
Dismiss
|
||||
@@ -219,45 +291,59 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="w-10 px-5 py-4">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-5 py-4 font-semibold">City</th>
|
||||
<th className="px-5 py-4 font-semibold">Location</th>
|
||||
<th className="px-5 py-4 font-semibold">Date</th>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Brand</th>
|
||||
<th className="px-5 py-4 font-semibold">Status</th>
|
||||
<th className="px-5 py-4 font-semibold" />
|
||||
<th className="px-4 py-3 font-semibold">When</th>
|
||||
<th className="px-4 py-3 font-semibold">Where</th>
|
||||
<th className="px-4 py-3 font-semibold">Venue</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-12 px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your search."
|
||||
: "No stops found. Create one to get started."}
|
||||
<td colSpan={6} className="px-4 py-14 text-center">
|
||||
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
||||
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your filters"
|
||||
: "No stops yet"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "Try a different search or clear the filters above."
|
||||
: "Use the buttons above to upload a schedule or add your first stop."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -268,52 +354,101 @@ export default function StopTableClient({ stops }: Props) {
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isSelected={selectedStops.has(stop.id)}
|
||||
onToggleSelect={() => toggleStopSelection(stop.id)}
|
||||
onToggleSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStopSelection(stop.id);
|
||||
}}
|
||||
onOpen={() => setOpenStopId(stop.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
onClose={() => setShowImport(false)}
|
||||
onComplete={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddStopModal
|
||||
isOpen={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
|
||||
{openStopId && (
|
||||
<StopDetailModal
|
||||
stopId={openStopId}
|
||||
onClose={() => setOpenStopId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): { date: string; weekday: string } {
|
||||
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
|
||||
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
|
||||
if (!y || !m || !d) return { date: iso, weekday: "" };
|
||||
const dt = new Date(y, m - 1, d);
|
||||
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
||||
return { date: `${month} ${d}`, weekday };
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
// Stored as HH:MM (24h). Display as 10:00 AM.
|
||||
if (!t) return "";
|
||||
const [hStr, mStr] = t.split(":");
|
||||
const h = parseInt(hStr, 10);
|
||||
if (Number.isNaN(h)) return t;
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${hh}:${mStr} ${period}`;
|
||||
}
|
||||
|
||||
function StopRowBase({
|
||||
stop,
|
||||
onDeleted,
|
||||
onDeleteError,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
}: {
|
||||
stop: Stop;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onToggleSelect: (e: React.MouseEvent) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
async function handlePublish(stopId: string) {
|
||||
setPublishingId(stopId);
|
||||
setOpenMenu(null);
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
const { date, weekday } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
async function handlePublish() {
|
||||
setPublishingId(stop.id);
|
||||
const result = await publishStop(stop.id, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", "The stop is now visible on the storefront");
|
||||
startTransition(() => router.refresh());
|
||||
onDeleted();
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(stopId: string) {
|
||||
setDeletingId(stopId);
|
||||
async function handleDelete() {
|
||||
setDeletingId(stop.id);
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/delete_stop`,
|
||||
{
|
||||
@@ -322,7 +457,7 @@ function StopRowBase({
|
||||
apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
|
||||
body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
@@ -338,69 +473,88 @@ function StopRowBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
|
||||
<td className="px-5 py-4">
|
||||
<tr
|
||||
onClick={onOpen}
|
||||
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
|
||||
>
|
||||
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
onChange={() => {}}
|
||||
onClick={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
aria-label={`Select stop in ${stop.city}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors"
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</Link>
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
|
||||
{weekday} {time && `· ${time}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
|
||||
|
||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
|
||||
{Array.isArray(stop.brands)
|
||||
? stop.brands[0]?.name
|
||||
: stop.brands?.name}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
|
||||
{stop.address && (
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
|
||||
{stop.address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-500"
|
||||
: stop.active
|
||||
? "bg-emerald-500"
|
||||
: "bg-stone-400"
|
||||
}`}
|
||||
/>
|
||||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
<AdminIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="More options"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
||||
}}
|
||||
className="opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||
<circle cx="10" cy="4" r="1.5" />
|
||||
<circle cx="10" cy="10" r="1.5" />
|
||||
<circle cx="10" cy="16" r="1.5" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
|
||||
@@ -408,36 +562,42 @@ function StopRowBase({
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
||||
{stop.status === "draft" && (
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handlePublish(stop.id)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePublish();
|
||||
}}
|
||||
disabled={publishingId === stop.id}
|
||||
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
|
||||
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{publishingId === stop.id ? "Publishing..." : "Publish"}
|
||||
</AdminButton>
|
||||
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Duplicate
|
||||
</a>
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
|
||||
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(stop.id);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</AdminButton>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -446,7 +606,11 @@ function StopRowBase({
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
@@ -455,22 +619,29 @@ function StopRowBase({
|
||||
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(stop.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={deletingId === stop.id}
|
||||
isLoading={deletingId === stop.id}
|
||||
>
|
||||
{deletingId === stop.id ? "..." : "Delete"}
|
||||
{deletingId === stop.id ? "Deleting…" : "Delete"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,4 +653,4 @@ function StopRowBase({
|
||||
);
|
||||
}
|
||||
|
||||
const StopRow = React.memo(StopRowBase);
|
||||
const StopRow = React.memo(StopRowBase);
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
import { useToast } from "@/components/admin/design-system";
|
||||
import type { StopForView } from "./StopsViewClient";
|
||||
|
||||
type Props = {
|
||||
stops: StopForView[];
|
||||
};
|
||||
|
||||
/* ================================================================== */
|
||||
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
|
||||
/* ================================================================== */
|
||||
|
||||
function ymd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function parseYmd(s: string): Date | null {
|
||||
// Treat YYYY-MM-DD as a local date (no UTC shift)
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const [, y, mo, d] = m;
|
||||
return new Date(Number(y), Number(mo) - 1, Number(d));
|
||||
}
|
||||
|
||||
function todayYmd(): string {
|
||||
return ymd(new Date());
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const MONTH_NAMES_ITALIC = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function buildMonthGrid(month: Date): Date[] {
|
||||
// Start at the Sunday on or before the 1st of the month
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const startDayOfWeek = first.getDay(); // 0 = Sun
|
||||
const start = new Date(first);
|
||||
start.setDate(1 - startDayOfWeek);
|
||||
|
||||
// 6 weeks = 42 cells
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
cells.push(d);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
|
||||
if (stop.status === "draft") return "draft";
|
||||
if (stop.active) return "active";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function statusLabel(s: "active" | "draft" | "inactive"): string {
|
||||
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
function brandName(s: StopForView): string {
|
||||
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
|
||||
}
|
||||
|
||||
function formatTime12(t: string | null | undefined): string {
|
||||
if (!t) return "—";
|
||||
// t is "HH:MM" or "HH:MM:SS"
|
||||
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||
const h = Number(hStr);
|
||||
const ampm = h >= 12 ? "pm" : "am";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${mStr} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatTimeCompact(t: string | null | undefined): string {
|
||||
if (!t) return "—";
|
||||
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||
const h = Number(hStr);
|
||||
const ampm = h >= 12 ? "p" : "a";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${mStr}${ampm}`;
|
||||
}
|
||||
|
||||
function formatDateLong(d: Date): string {
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||
const month = MONTH_NAMES[d.getMonth()];
|
||||
return `${weekday}, ${month} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
function formatDateLongSpelled(d: Date): string {
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||
const month = MONTH_NAMES[d.getMonth()];
|
||||
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Component */
|
||||
/* ================================================================== */
|
||||
|
||||
const MAX_EVENTS_PER_CELL = 3;
|
||||
|
||||
export default function StopsCalendarClient({ stops }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const today = new Date();
|
||||
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
});
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activePopover, setActivePopover] = useState<{
|
||||
stopId: string;
|
||||
cellRect: DOMRect;
|
||||
} | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
// Bucket stops by YYYY-MM-DD
|
||||
const stopsByDate = useMemo(() => {
|
||||
const map = new Map<string, StopForView[]>();
|
||||
for (const s of stops) {
|
||||
if (!s.date) continue;
|
||||
const list = map.get(s.date) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.date, list);
|
||||
}
|
||||
// Sort each day by time
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||
}
|
||||
return map;
|
||||
}, [stops]);
|
||||
|
||||
const todayStr = useMemo(() => todayYmd(), []);
|
||||
|
||||
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
|
||||
|
||||
// Earliest/latest stop dates — used to enable/disable nav
|
||||
const dateRange = useMemo(() => {
|
||||
if (stops.length === 0) return null;
|
||||
const dates = stops.map((s) => s.date).filter(Boolean).sort();
|
||||
return { min: dates[0], max: dates[dates.length - 1] };
|
||||
}, [stops]);
|
||||
|
||||
const isFirstMonth = useMemo(() => {
|
||||
if (!dateRange?.min) return false;
|
||||
const minDate = parseYmd(dateRange.min);
|
||||
if (!minDate) return false;
|
||||
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
|
||||
}, [dateRange, viewMonth]);
|
||||
|
||||
const isLastMonth = useMemo(() => {
|
||||
if (!dateRange?.max) return false;
|
||||
const maxDate = parseYmd(dateRange.max);
|
||||
if (!maxDate) return false;
|
||||
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
|
||||
}, [dateRange, viewMonth]);
|
||||
|
||||
function goPrevMonth() {
|
||||
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
|
||||
}
|
||||
function goNextMonth() {
|
||||
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
|
||||
}
|
||||
function goToday() {
|
||||
const today = new Date();
|
||||
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
setSelectedDate(todayYmd());
|
||||
}
|
||||
|
||||
// Close popover on outside click / Escape
|
||||
useEffect(() => {
|
||||
if (!activePopover) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
|
||||
setActivePopover(null);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setActivePopover(null);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [activePopover]);
|
||||
|
||||
// Close drawer on Escape
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setSelectedDate(null);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [selectedDate]);
|
||||
|
||||
// Lock body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
|
||||
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
|
||||
}, []);
|
||||
|
||||
const handlePublish = useCallback(
|
||||
async (stop: StopForView) => {
|
||||
setActivePopover(null);
|
||||
setPublishingId(stop.id);
|
||||
const result = await publishStop(stop.id, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
},
|
||||
[router, showSuccess, showError]
|
||||
);
|
||||
|
||||
const activeStop = useMemo(
|
||||
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
|
||||
[activePopover, stops]
|
||||
);
|
||||
|
||||
const selectedDayStops = useMemo(() => {
|
||||
if (!selectedDate) return [];
|
||||
return stopsByDate.get(selectedDate) ?? [];
|
||||
}, [selectedDate, stopsByDate]);
|
||||
|
||||
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
|
||||
|
||||
// Compute popover position with viewport edge detection
|
||||
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
|
||||
if (!activePopover) return null;
|
||||
const POPOVER_W = 288; // 18rem
|
||||
const MARGIN = 8;
|
||||
const rect = activePopover.cellRect;
|
||||
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
// Default: place below the chip, left-aligned
|
||||
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
|
||||
const top = rect.bottom + MARGIN;
|
||||
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
|
||||
if (left < 8) left = 8;
|
||||
return { left, top };
|
||||
}, [activePopover]);
|
||||
|
||||
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
|
||||
if (!activePopover || !popoverStyle) return null;
|
||||
const rect = activePopover.cellRect;
|
||||
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
|
||||
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
|
||||
}, [activePopover, popoverStyle]);
|
||||
|
||||
// === Render ===
|
||||
return (
|
||||
<>
|
||||
<div className="ha-calendar">
|
||||
{/* Header */}
|
||||
<div className="ha-calendar-header">
|
||||
<div className="ha-calendar-title-block">
|
||||
<span className="ha-calendar-eyebrow">
|
||||
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
|
||||
</span>
|
||||
<h2 className="ha-calendar-title">
|
||||
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
|
||||
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ha-calendar-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-nav-btn"
|
||||
onClick={goPrevMonth}
|
||||
disabled={isFirstMonth}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" className="ha-calendar-today" onClick={goToday}>
|
||||
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-nav-btn"
|
||||
onClick={goNextMonth}
|
||||
disabled={isLastMonth}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DOW header */}
|
||||
<div className="ha-calendar-dow" role="row">
|
||||
{DOW_LABELS.map((label) => (
|
||||
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="ha-calendar-grid" role="grid">
|
||||
{monthCells.map((d) => {
|
||||
const key = ymd(d);
|
||||
const dayStops = stopsByDate.get(key) ?? [];
|
||||
const isOut = d.getMonth() !== viewMonth.getMonth();
|
||||
const isToday = key === todayStr;
|
||||
const hasStops = dayStops.length > 0;
|
||||
const dow = d.getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isSelected = selectedDate === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="gridcell"
|
||||
tabIndex={hasStops ? 0 : -1}
|
||||
onClick={() => hasStops && setSelectedDate(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (hasStops && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setSelectedDate(key);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"ha-calendar-cell",
|
||||
isOut ? "ha-calendar-cell--out" : "",
|
||||
isWeekend ? "ha-calendar-cell--weekend" : "",
|
||||
hasStops ? "ha-calendar-cell--has-stops" : "",
|
||||
isToday ? "ha-calendar-cell--today" : "",
|
||||
isSelected ? "ha-calendar-cell--selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-label={hasStops ? `${formatDateLong(d)} — ${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
|
||||
>
|
||||
<div className="ha-calendar-daynum">
|
||||
<span>{d.getDate()}</span>
|
||||
{isToday && <span className="ha-calendar-today-dot" />}
|
||||
</div>
|
||||
<div className="ha-calendar-events">
|
||||
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
|
||||
<EventChip
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
isPublishing={publishingId === stop.id}
|
||||
onOpen={(rect) => openStop(stop, rect)}
|
||||
isActive={activePopover?.stopId === stop.id}
|
||||
/>
|
||||
))}
|
||||
{dayStops.length > MAX_EVENTS_PER_CELL && (
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-event-more"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedDate(key);
|
||||
}}
|
||||
>
|
||||
+{dayStops.length - MAX_EVENTS_PER_CELL} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="ha-calendar-legend">
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
|
||||
Active
|
||||
</span>
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
|
||||
Draft
|
||||
</span>
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
|
||||
Inactive
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
|
||||
Tip — click any day with stops to see the full route · click an event for details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event popover */}
|
||||
{activeStop && activePopover && popoverStyle && (
|
||||
<EventPopover
|
||||
stop={activeStop}
|
||||
isPublishing={publishingId === activeStop.id}
|
||||
onPublish={() => handlePublish(activeStop)}
|
||||
onOpenRoute={() => {
|
||||
setActivePopover(null);
|
||||
setSelectedDate(activeStop.date);
|
||||
}}
|
||||
style={popoverStyle}
|
||||
arrowStyle={popoverArrowStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day-route drawer */}
|
||||
{selectedDate && selectedDayDate && (
|
||||
<DayRouteDrawer
|
||||
date={selectedDayDate}
|
||||
dateStr={selectedDate}
|
||||
stops={selectedDayStops}
|
||||
onClose={() => setSelectedDate(null)}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* EventChip — single stop on a day cell */
|
||||
/* ================================================================== */
|
||||
|
||||
function EventChip({
|
||||
stop,
|
||||
onOpen,
|
||||
isActive,
|
||||
isPublishing,
|
||||
}: {
|
||||
stop: StopForView;
|
||||
onOpen: (rect: DOMRect) => void;
|
||||
isActive: boolean;
|
||||
isPublishing: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-event-chip
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
|
||||
}}
|
||||
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
|
||||
title={`${stop.city}, ${stop.state} — ${stop.location}`}
|
||||
>
|
||||
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
|
||||
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* EventPopover — floating detail card */
|
||||
/* ================================================================== */
|
||||
|
||||
function EventPopover({
|
||||
stop,
|
||||
isPublishing,
|
||||
onPublish,
|
||||
onOpenRoute,
|
||||
style,
|
||||
arrowStyle,
|
||||
}: {
|
||||
stop: StopForView;
|
||||
isPublishing: boolean;
|
||||
onPublish: () => void;
|
||||
onOpenRoute: () => void;
|
||||
style: React.CSSProperties;
|
||||
arrowStyle: React.CSSProperties | null;
|
||||
}) {
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<div
|
||||
data-event-popover
|
||||
className="ha-event-popover"
|
||||
style={style}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
|
||||
<div className="ha-event-popover-eyebrow">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
|
||||
}}
|
||||
/>
|
||||
{statusLabel(status)} · {brandName(stop)}
|
||||
</div>
|
||||
<h3 className="ha-event-popover-title">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="ha-event-popover-location">{stop.location}</p>
|
||||
|
||||
<div className="ha-event-popover-grid">
|
||||
<div className="ha-event-popover-field">
|
||||
<span className="ha-event-popover-field-label">Pickup</span>
|
||||
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
|
||||
</div>
|
||||
<div className="ha-event-popover-field">
|
||||
<span className="ha-event-popover-field-label">Cutoff</span>
|
||||
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
|
||||
<span className="ha-event-popover-field-label">Address</span>
|
||||
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
|
||||
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ha-event-popover-actions">
|
||||
{status === "draft" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
disabled={isPublishing}
|
||||
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||
>
|
||||
{isPublishing ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRoute}
|
||||
className="ha-event-popover-btn"
|
||||
>
|
||||
View route
|
||||
</button>
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||
>
|
||||
Edit
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* DayRouteDrawer — full day's route on the side */
|
||||
/* ================================================================== */
|
||||
|
||||
function DayRouteDrawer({
|
||||
date,
|
||||
dateStr,
|
||||
stops,
|
||||
onClose,
|
||||
onPublish,
|
||||
}: {
|
||||
date: Date;
|
||||
dateStr: string;
|
||||
stops: StopForView[];
|
||||
onClose: () => void;
|
||||
onPublish: (stop: StopForView) => void;
|
||||
}) {
|
||||
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
|
||||
const counts = useMemo(() => {
|
||||
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
|
||||
const draft = sorted.filter((s) => s.status === "draft").length;
|
||||
const total = sorted.length;
|
||||
return { active, draft, total };
|
||||
}, [sorted]);
|
||||
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
|
||||
const routeNumber = useMemo(() => {
|
||||
// Editorial "Route 03" — count of stops with status badge
|
||||
return String(sorted.length).padStart(2, "0");
|
||||
}, [sorted.length]);
|
||||
|
||||
const earliest = sorted[0]?.time;
|
||||
const latest = sorted[sorted.length - 1]?.time;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
|
||||
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
|
||||
<header className="ha-drawer-header">
|
||||
<div>
|
||||
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
|
||||
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
|
||||
<p className="ha-drawer-subtitle">
|
||||
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
|
||||
{earliest && latest && sorted.length > 1 && (
|
||||
<> · {formatTime12(earliest)} – {formatTime12(latest)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="ha-drawer-body">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="ha-drawer-empty">
|
||||
<p className="ha-drawer-empty-mark">No route on this day</p>
|
||||
<p className="ha-drawer-empty-text">
|
||||
No stops are scheduled for {formatDateLongSpelled(date)}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="ha-route-summary">
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{counts.total}</span>
|
||||
<span className="ha-route-summary-label">Stops</span>
|
||||
</div>
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{counts.active}</span>
|
||||
<span className="ha-route-summary-label">Live</span>
|
||||
</div>
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{brands.length}</span>
|
||||
<span className="ha-route-summary-label">Brands</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route spine */}
|
||||
<div className="ha-route-list">
|
||||
{sorted.map((stop, idx) => {
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<article key={stop.id} className="ha-route-stop">
|
||||
<div className="ha-route-stop-spine">
|
||||
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
|
||||
{String(idx + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<div className="ha-route-stop-line" />
|
||||
</div>
|
||||
<div className="ha-route-stop-content">
|
||||
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
|
||||
<h3 className="ha-route-stop-name">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="ha-route-stop-location">{stop.location}</p>
|
||||
<div className="ha-route-stop-meta">
|
||||
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
|
||||
<span
|
||||
className="ha-route-stop-meta-pill"
|
||||
style={{
|
||||
background:
|
||||
status === "active"
|
||||
? "var(--admin-accent-light)"
|
||||
: status === "draft"
|
||||
? "rgba(245, 158, 11, 0.1)"
|
||||
: "var(--admin-bg-subtle)",
|
||||
color:
|
||||
status === "active"
|
||||
? "var(--admin-accent-text)"
|
||||
: status === "draft"
|
||||
? "#92400e"
|
||||
: "var(--admin-text-secondary)",
|
||||
borderColor:
|
||||
status === "active"
|
||||
? "var(--admin-accent)"
|
||||
: status === "draft"
|
||||
? "rgba(245, 158, 11, 0.3)"
|
||||
: "var(--admin-border-light)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
{stop.cutoff_time && (
|
||||
<span className="ha-route-stop-meta-pill">
|
||||
Cutoff {formatTime12(stop.cutoff_time)}
|
||||
</span>
|
||||
)}
|
||||
{stop.address && (
|
||||
<span className="ha-route-stop-meta-pill">
|
||||
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ha-route-stop-actions">
|
||||
{status === "draft" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPublish(stop)}
|
||||
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||
>
|
||||
Edit
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="ha-route-stop-action"
|
||||
>
|
||||
Duplicate
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
/** Hide on the Locations tab — actions belong to the Stops tab only. */
|
||||
tab?: "stops" | "locations";
|
||||
};
|
||||
|
||||
export default function StopsHeaderActions({ brandId }: Props) {
|
||||
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (tab !== "stops") return null;
|
||||
|
||||
function handleImportComplete(count: number) {
|
||||
router.refresh();
|
||||
}
|
||||
@@ -66,4 +70,4 @@ export default function StopsHeaderActions({ brandId }: Props) {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
active: "stops" | "locations";
|
||||
stopCount: number;
|
||||
locationCount: number;
|
||||
stopsSublabel?: string; // e.g. "269 · 7 cities"
|
||||
locationsSublabel?: string; // e.g. "41 · 8 cities"
|
||||
};
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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>
|
||||
);
|
||||
|
||||
const PinIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function StopsLocationsTabs({
|
||||
active,
|
||||
stopCount,
|
||||
locationCount,
|
||||
stopsSublabel,
|
||||
locationsSublabel,
|
||||
}: Props) {
|
||||
const tabs = [
|
||||
{
|
||||
value: "stops" as const,
|
||||
label: "Stops",
|
||||
count: stopCount,
|
||||
sublabel: stopsSublabel,
|
||||
icon: <StopIcon />,
|
||||
href: "/admin/stops",
|
||||
},
|
||||
{
|
||||
value: "locations" as const,
|
||||
label: "Locations",
|
||||
count: locationCount,
|
||||
sublabel: locationsSublabel,
|
||||
icon: <PinIcon />,
|
||||
href: "/admin/stops?tab=locations",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
|
||||
role="tablist"
|
||||
aria-label="Stops and Locations tabs"
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.value === active;
|
||||
return (
|
||||
<Link
|
||||
key={t.value}
|
||||
href={t.href}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`
|
||||
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
|
||||
text-sm font-semibold transition-all duration-200
|
||||
${isActive
|
||||
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm"
|
||||
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`}
|
||||
aria-hidden
|
||||
>
|
||||
{t.icon}
|
||||
</span>
|
||||
<span>{t.label}</span>
|
||||
<span
|
||||
className={`
|
||||
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
|
||||
text-[11px] font-bold tabular-nums
|
||||
${isActive
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{t.sublabel && (
|
||||
<span
|
||||
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`}
|
||||
>
|
||||
· {t.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
|
||||
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
|
||||
|
||||
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
|
||||
export type StopsViewMode = "calendar" | "table";
|
||||
|
||||
export type StopForView = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: StopForView[];
|
||||
};
|
||||
|
||||
export default function StopsViewClient({ stops }: Props) {
|
||||
const [view, setView] = useState<StopsViewMode>("calendar");
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
|
||||
|
||||
// Apply shared filter so both views agree on what's visible
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.state.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
const isDraft = s.status === "draft";
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && !isDraft) ||
|
||||
(statusFilter === "inactive" && !s.active && !isDraft) ||
|
||||
(statusFilter === "draft" && isDraft);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: stops.length,
|
||||
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter((s) => s.status === "draft").length,
|
||||
}),
|
||||
[stops]
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ value: "all", label: "All", count: counts.all },
|
||||
{ value: "active", label: "Active", count: counts.active },
|
||||
{ value: "inactive", label: "Inactive", count: counts.inactive },
|
||||
{ value: "draft", label: "Draft", count: counts.draft },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter / search / view-toggle bar — shared across both views */}
|
||||
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
|
||||
tabs={tabs}
|
||||
size="sm"
|
||||
showCounts
|
||||
/>
|
||||
<AdminSearchInput
|
||||
placeholder="Search city, state, or venue…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
showClear
|
||||
className="flex-1 min-w-48 max-w-72"
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
|
||||
</span>
|
||||
<div className="sm:ml-auto">
|
||||
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === "calendar"}
|
||||
onClick={() => setView("calendar")}
|
||||
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
Calendar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === "table"}
|
||||
onClick={() => setView("table")}
|
||||
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "calendar" ? (
|
||||
<StopsCalendarClient stops={filtered} />
|
||||
) : (
|
||||
<StopTableClient stops={filtered} hideInternalFilterBar />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
type Props = {
|
||||
tabKey: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fades the content of a tabbed panel when the active tab changes.
|
||||
* The tab key drives the animation, so switching tabs triggers a quick
|
||||
* fade-in of the new content. No motion when the tab key is stable
|
||||
* (i.e. on first render of a given tab).
|
||||
*/
|
||||
export function TabSwitcher({ tabKey, children }: Props) {
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={tabKey}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type Stop,
|
||||
type StopStatus,
|
||||
formatMonthYear,
|
||||
formatTime12,
|
||||
getStopDate,
|
||||
getStopStatus,
|
||||
isSameDay,
|
||||
} from "./types";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
|
||||
active: {
|
||||
bg: "bg-[var(--admin-accent-light)]",
|
||||
text: "text-[var(--admin-accent-text)]",
|
||||
dot: "bg-[var(--admin-accent-dot)]",
|
||||
border: "border-[var(--admin-accent)]/40",
|
||||
},
|
||||
draft: {
|
||||
bg: "bg-amber-50",
|
||||
text: "text-amber-800",
|
||||
dot: "bg-amber-500",
|
||||
border: "border-amber-300",
|
||||
},
|
||||
inactive: {
|
||||
bg: "bg-stone-100",
|
||||
text: "text-stone-500",
|
||||
dot: "bg-stone-400",
|
||||
border: "border-stone-300",
|
||||
},
|
||||
};
|
||||
|
||||
function buildMonthGrid(viewYear: number, viewMonth: number) {
|
||||
// Sunday-first 6-row grid
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
const startWeekday = first.getDay();
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
|
||||
const cells: { date: Date; inMonth: boolean }[] = [];
|
||||
// Leading days from previous month
|
||||
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||
const d = new Date(viewYear, viewMonth, -i);
|
||||
cells.push({ date: d, inMonth: false });
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
|
||||
}
|
||||
// Trailing days to fill 6 rows = 42 cells
|
||||
while (cells.length < 42) {
|
||||
const last = cells[cells.length - 1].date;
|
||||
const next = new Date(last);
|
||||
next.setDate(last.getDate() + 1);
|
||||
cells.push({ date: next, inMonth: false });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default function StopsCalendar({ stops }: Props) {
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(ymd(today));
|
||||
|
||||
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
|
||||
|
||||
const stopsByDate = useMemo(() => {
|
||||
const map = new Map<string, Stop[]>();
|
||||
for (const s of stops) {
|
||||
const d = getStopDate(s);
|
||||
if (!d) continue;
|
||||
const k = ymd(d);
|
||||
const arr = map.get(k) ?? [];
|
||||
arr.push(s);
|
||||
map.set(k, arr);
|
||||
}
|
||||
// sort each bucket by time
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||
}
|
||||
return map;
|
||||
}, [stops]);
|
||||
|
||||
const visibleStopsByDate = useMemo(() => {
|
||||
const map = new Map<string, Stop[]>();
|
||||
for (const [k, arr] of stopsByDate.entries()) {
|
||||
map.set(
|
||||
k,
|
||||
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [stopsByDate]);
|
||||
|
||||
const monthStopsCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const cell of cells) {
|
||||
if (!cell.inMonth) continue;
|
||||
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
|
||||
}
|
||||
return count;
|
||||
}, [cells, stopsByDate]);
|
||||
|
||||
const upcomingWeek = useMemo(() => {
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() + 7);
|
||||
let count = 0;
|
||||
for (const s of stops) {
|
||||
const d = getStopDate(s);
|
||||
if (!d) continue;
|
||||
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
|
||||
}
|
||||
return count;
|
||||
}, [stops, today]);
|
||||
|
||||
const goPrev = () => {
|
||||
setView((v) => {
|
||||
const m = v.month - 1;
|
||||
if (m < 0) return { year: v.year - 1, month: 11 };
|
||||
return { ...v, month: m };
|
||||
});
|
||||
setSelectedDate(null);
|
||||
};
|
||||
const goNext = () => {
|
||||
setView((v) => {
|
||||
const m = v.month + 1;
|
||||
if (m > 11) return { year: v.year + 1, month: 0 };
|
||||
return { ...v, month: m };
|
||||
});
|
||||
setSelectedDate(null);
|
||||
};
|
||||
const goToday = () => {
|
||||
setView({ year: today.getFullYear(), month: today.getMonth() });
|
||||
setSelectedDate(ymd(today));
|
||||
};
|
||||
|
||||
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
|
||||
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
|
||||
|
||||
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
|
||||
{/* Calendar card */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
|
||||
aria-label="Stops calendar"
|
||||
>
|
||||
{/* Decorative paper texture */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.4]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
|
||||
{monthLabel.split(" ")[0]}
|
||||
<span className="text-[var(--admin-accent)] italic font-light">
|
||||
{" "}
|
||||
{monthLabel.split(" ")[1]}
|
||||
</span>
|
||||
</h2>
|
||||
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
|
||||
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={goPrev}
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToday}
|
||||
disabled={isCurrentMonth}
|
||||
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Weekday header */}
|
||||
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
|
||||
{WEEKDAY_LABELS.map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
|
||||
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="relative grid grid-cols-7 grid-rows-6">
|
||||
{cells.map((cell, idx) => {
|
||||
const k = ymd(cell.date);
|
||||
const dayStops = stopsByDate.get(k) ?? [];
|
||||
const visibleStops = visibleStopsByDate.get(k) ?? [];
|
||||
const isToday = isSameDay(cell.date, today);
|
||||
const isSelected = k === selectedDate;
|
||||
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
|
||||
const overflow = dayStops.length - visibleStops.length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(k)}
|
||||
className={`
|
||||
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
|
||||
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
|
||||
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
|
||||
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
|
||||
`}
|
||||
>
|
||||
{/* Date number row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`
|
||||
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
|
||||
${isToday
|
||||
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
|
||||
: !cell.inMonth
|
||||
? "text-[var(--admin-text-muted)]/50"
|
||||
: isSelected
|
||||
? "text-[var(--admin-accent-text)]"
|
||||
: "text-[var(--admin-text-primary)]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</span>
|
||||
{dayStops.length > 0 && cell.inMonth && (
|
||||
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{dayStops.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stop chips */}
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
{visibleStops.slice(0, 3).map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const st = STATUS_STYLES[status];
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
|
||||
<span className="truncate">
|
||||
{s.time && (
|
||||
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
|
||||
)}
|
||||
{s.city}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
|
||||
+{overflow} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
|
||||
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
|
||||
const st = STATUS_STYLES[s];
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<span>
|
||||
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
|
||||
{upcomingWeek}
|
||||
</span>{" "}
|
||||
upcoming in 7d
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
{/* Day detail panel */}
|
||||
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
<div className="border-b border-[var(--admin-border)] px-5 py-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
|
||||
{selectedDate
|
||||
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
|
||||
: "Pick a day"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
{selectedDate
|
||||
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "Select a date on the calendar"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
|
||||
{!selectedDate ? (
|
||||
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
No day selected
|
||||
</div>
|
||||
) : selectedDayStops.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Free day on the route
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{selectedDayStops.map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const st = STATUS_STYLES[status];
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
href={`/admin/stops/${s.id}`}
|
||||
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
|
||||
>
|
||||
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
|
||||
{formatTime12(s.time).split(" ")[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
|
||||
{s.city}, {s.state}
|
||||
</p>
|
||||
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{formatTime12(s.time).split(" ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
|
||||
{status}
|
||||
</span>
|
||||
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
|
||||
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { type Stop, type StopView, getStopStatus } from "./types";
|
||||
import StopsCalendar from "./StopsCalendar";
|
||||
import StopsLocations from "./StopsLocations";
|
||||
import StopsList from "./StopsList";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
|
||||
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
|
||||
{ value: "list", label: "List", hint: "All stops in order" },
|
||||
];
|
||||
|
||||
export default function StopsDashboardClient({ stops }: Props) {
|
||||
const [view, setView] = useState<StopView>("calendar");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = stops.length;
|
||||
const active = stops.filter((s) => getStopStatus(s) === "active").length;
|
||||
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
|
||||
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
|
||||
const venues = new Set(
|
||||
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
|
||||
).size;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const in7 = new Date(today);
|
||||
in7.setDate(today.getDate() + 7);
|
||||
const upcoming = stops.filter((s) => {
|
||||
if (!s.date) return false;
|
||||
if (getStopStatus(s) === "inactive") return false;
|
||||
const d = new Date(s.date + "T00:00:00");
|
||||
return d >= today && d < in7;
|
||||
}).length;
|
||||
return { total, active, draft, cities, venues, upcoming };
|
||||
}, [stops]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top stats strip — almanac style */}
|
||||
<section
|
||||
aria-label="Stops overview"
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
|
||||
Harvest Dispatch · Almanac
|
||||
</p>
|
||||
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
|
||||
{stats.total === 0
|
||||
? "No stops scheduled"
|
||||
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
|
||||
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
|
||||
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab nav — binder-style with notched corners */}
|
||||
<div className="flex items-end gap-1">
|
||||
{TABS.map((t) => {
|
||||
const isActive = t.value === view;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setView(t.value)}
|
||||
aria-pressed={isActive}
|
||||
className={`
|
||||
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
|
||||
${isActive
|
||||
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
|
||||
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
|
||||
{t.label}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
|
||||
{t.hint}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cells */}
|
||||
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
|
||||
<AlmanacStat
|
||||
numeral="I"
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
accent
|
||||
/>
|
||||
<AlmanacStat
|
||||
numeral="II"
|
||||
label="Upcoming 7d"
|
||||
value={stats.upcoming}
|
||||
accent={stats.upcoming > 0}
|
||||
/>
|
||||
<AlmanacStat
|
||||
numeral="III"
|
||||
label="Cities"
|
||||
value={stats.cities}
|
||||
/>
|
||||
<AlmanacStat
|
||||
numeral="IV"
|
||||
label="Venues"
|
||||
value={stats.venues}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Active view */}
|
||||
<section>
|
||||
{view === "calendar" && <StopsCalendar stops={stops} />}
|
||||
{view === "locations" && <StopsLocations stops={stops} />}
|
||||
{view === "list" && <StopsList stops={stops} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlmanacStat({
|
||||
numeral,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
}: {
|
||||
numeral: string;
|
||||
label: string;
|
||||
value: number;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
|
||||
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{numeral}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`font-display text-2xl font-medium leading-none tabular-nums ${
|
||||
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { type Stop, getStopStatus } from "./types";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
// Minimal list view used inside the StopsDashboard tab nav.
|
||||
// It is a calmer, read-only list; for bulk publish/edit use the
|
||||
// dedicated /admin/stops page.
|
||||
export default function StopsList({ stops }: Props) {
|
||||
const sorted = useMemo(() => {
|
||||
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
}, [stops]);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
|
||||
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||
Switch to a different tab to add stops.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
{sorted.map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const dot =
|
||||
status === "active"
|
||||
? "bg-[var(--admin-accent-dot)]"
|
||||
: status === "draft"
|
||||
? "bg-amber-500"
|
||||
: "bg-stone-400";
|
||||
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
|
||||
return (
|
||||
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
|
||||
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
|
||||
{s.time ? s.time.slice(0, 5) : "—"}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
{s.city}, {s.state}
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
|
||||
</span>
|
||||
{brand && (
|
||||
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`font-mono text-[9px] uppercase tracking-wider ${
|
||||
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type LocationGroup,
|
||||
type Stop,
|
||||
formatTime12,
|
||||
getStopStatus,
|
||||
} from "./types";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
|
||||
const map = new Map<string, LocationGroup>();
|
||||
for (const s of stops) {
|
||||
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
|
||||
let group = map.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
city: s.city || "Unknown",
|
||||
state: s.state || "",
|
||||
venueCount: 0,
|
||||
total: 0,
|
||||
active: 0,
|
||||
draft: 0,
|
||||
inactive: 0,
|
||||
upcoming: 0,
|
||||
nextDate: null,
|
||||
firstDate: null,
|
||||
lastDate: null,
|
||||
sampleVenue: s.location,
|
||||
stops: [],
|
||||
};
|
||||
map.set(key, group);
|
||||
}
|
||||
group.stops.push(s);
|
||||
group.total += 1;
|
||||
const status = getStopStatus(s);
|
||||
if (status === "active") group.active += 1;
|
||||
else if (status === "draft") group.draft += 1;
|
||||
else group.inactive += 1;
|
||||
|
||||
if (s.date) {
|
||||
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
|
||||
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
|
||||
}
|
||||
}
|
||||
// post-process: count distinct venues, upcoming stops
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
for (const g of map.values()) {
|
||||
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
|
||||
g.venueCount = Math.max(1, venues.size);
|
||||
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
|
||||
const future = g.stops
|
||||
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
|
||||
.map((s) => s.date)
|
||||
.sort();
|
||||
g.nextDate = future[0] ?? null;
|
||||
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
// active locations with upcoming stops first, sorted by next date; then by city
|
||||
if (a.nextDate && !b.nextDate) return -1;
|
||||
if (!a.nextDate && b.nextDate) return 1;
|
||||
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
|
||||
return a.city.localeCompare(b.city);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateLabel(ymd: string | null): string {
|
||||
if (!ymd) return "—";
|
||||
const d = new Date(ymd + "T00:00:00");
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatRange(a: string | null, b: string | null): string {
|
||||
if (!a) return "—";
|
||||
if (!b || a === b) return formatDateLabel(a);
|
||||
return `${formatDateLabel(a)} → ${formatDateLabel(b)}`;
|
||||
}
|
||||
|
||||
export default function StopsLocations({ stops }: Props) {
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showOnlyActive, setShowOnlyActive] = useState(false);
|
||||
|
||||
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return groups.filter((g) => {
|
||||
if (q) {
|
||||
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
if (showOnlyActive && g.active === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}, [groups, query, showOnlyActive]);
|
||||
|
||||
// Aggregated stats
|
||||
const stats = useMemo(() => {
|
||||
const totalLocations = groups.length;
|
||||
const totalStops = stops.length;
|
||||
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
|
||||
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
|
||||
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
|
||||
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
|
||||
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
|
||||
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
|
||||
}, [groups, stops]);
|
||||
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
|
||||
No locations yet
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||
Create your first stop to see pickup locations here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats strip — almanac style */}
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{[
|
||||
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
|
||||
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
|
||||
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
|
||||
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
|
||||
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
|
||||
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.numeral}
|
||||
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
{s.numeral} · {s.label}
|
||||
</p>
|
||||
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{s.hint}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
|
||||
>
|
||||
{s.numeral}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Filter bar */}
|
||||
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search city, state, venue..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={showOnlyActive}
|
||||
onClick={() => setShowOnlyActive((v) => !v)}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
|
||||
Active only
|
||||
</span>
|
||||
</label>
|
||||
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{filtered.length} of {groups.length} locations
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{/* Location grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
|
||||
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((g, idx) => {
|
||||
const isOpen = expandedKey === g.key;
|
||||
return (
|
||||
<article
|
||||
key={g.key}
|
||||
className={`
|
||||
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
|
||||
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
|
||||
`}
|
||||
>
|
||||
{/* Decorative route number */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
|
||||
>
|
||||
{String(idx + 1).padStart(2, "0")}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedKey(isOpen ? null : g.key)}
|
||||
className="block w-full text-left p-5"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pin marker */}
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
|
||||
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
|
||||
{g.city}
|
||||
{g.state && (
|
||||
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
|
||||
{g.state}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
|
||||
{g.sampleVenue}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
|
||||
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat grid */}
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<Stat label="Stops" value={g.total} />
|
||||
<Stat label="Active" value={g.active} accent={g.active > 0} />
|
||||
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
|
||||
</div>
|
||||
|
||||
{/* Date range + drafts */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
|
||||
<div className="text-[var(--admin-text-muted)]">
|
||||
{g.firstDate ? (
|
||||
<>
|
||||
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
|
||||
{" · "}
|
||||
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
|
||||
</>
|
||||
) : (
|
||||
"No dates"
|
||||
)}
|
||||
</div>
|
||||
{g.draft > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
|
||||
{g.draft} draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next up ribbon */}
|
||||
{g.nextDate && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
|
||||
Next →
|
||||
</span>
|
||||
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
{formatDateLabel(g.nextDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded stop list */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
All stops · {g.stops.length}
|
||||
</h4>
|
||||
<Link
|
||||
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
|
||||
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
|
||||
>
|
||||
+ Add at this city
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{g.stops.map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const dot =
|
||||
status === "active"
|
||||
? "bg-[var(--admin-accent-dot)]"
|
||||
: status === "draft"
|
||||
? "bg-amber-500"
|
||||
: "bg-stone-400";
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
href={`/admin/stops/${s.id}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
|
||||
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
|
||||
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
|
||||
{formatTime12(s.time)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
|
||||
{s.location}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono text-[9px] uppercase tracking-wider ${
|
||||
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border px-2.5 py-2 ${
|
||||
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
|
||||
}`}
|
||||
>
|
||||
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
|
||||
<p
|
||||
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
|
||||
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type StopStatus = "draft" | "active" | "inactive";
|
||||
|
||||
export type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
time: string; // HH:MM
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
export type StopView = "calendar" | "locations" | "list";
|
||||
|
||||
export type LocationGroup = {
|
||||
key: string;
|
||||
city: string;
|
||||
state: string;
|
||||
venueCount: number; // distinct location strings at this city
|
||||
total: number;
|
||||
active: number;
|
||||
draft: number;
|
||||
inactive: number;
|
||||
upcoming: number;
|
||||
nextDate: string | null; // YYYY-MM-DD
|
||||
firstDate: string | null;
|
||||
lastDate: string | null;
|
||||
sampleVenue: string;
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
export function getStopStatus(s: Stop): StopStatus {
|
||||
if (s.status === "draft") return "draft";
|
||||
return s.active ? "active" : "inactive";
|
||||
}
|
||||
|
||||
export function getStopDate(s: Stop): Date | null {
|
||||
if (!s.date) return null;
|
||||
const d = new Date(s.date + "T00:00:00");
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function formatTime12(time: string): string {
|
||||
if (!time) return "—";
|
||||
const [h, m] = time.split(":");
|
||||
const hour = parseInt(h, 10);
|
||||
if (isNaN(hour)) return time;
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
export function formatMonthYear(date: Date): string {
|
||||
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type QuickCartSheetProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Optional override for the product just added (used when re-opening). */
|
||||
productName?: string;
|
||||
/** Optional message override (e.g. "Added ✓" vs default). */
|
||||
justAddedLabel?: string;
|
||||
};
|
||||
|
||||
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
/**
|
||||
* Slide-up cart drawer that appears immediately after the buyer adds an item.
|
||||
*
|
||||
* Mobile: bottom sheet that slides up from the bottom of the screen,
|
||||
* full width, ~85vh tall. Drag handle at top.
|
||||
* Desktop: right-side drawer (420px wide) that slides in from the right.
|
||||
*
|
||||
* Contains:
|
||||
* - "✓ Added" confirmation
|
||||
* - The added item card (name, price, qty, fulfillment)
|
||||
* - Express checkout row (Apple Pay / Google Pay / Shop Pay)
|
||||
* - Primary "Checkout" CTA
|
||||
* - Secondary "View full cart" link
|
||||
* - "Continue shopping" dismiss link
|
||||
*/
|
||||
export default function QuickCartSheet({
|
||||
open,
|
||||
onClose,
|
||||
productName,
|
||||
justAddedLabel,
|
||||
}: QuickCartSheetProps) {
|
||||
const { cart, subtotal, selectedStop, justAdded, dismissToast } = useCart();
|
||||
|
||||
// Lock body scroll while open (mobile only — desktop keeps scroll)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
|
||||
if (!isDesktop) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const displayName = productName ?? justAdded?.name ?? cart[0]?.name ?? "Your box";
|
||||
const addedAt = new Date();
|
||||
const headerLabel = justAddedLabel ?? "Just added";
|
||||
|
||||
function handleViewCart() {
|
||||
dismissToast();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleCheckout() {
|
||||
dismissToast();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Close cart"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT }}
|
||||
className="fixed inset-0 z-[60] bg-stone-950/55 backdrop-blur-[3px] cursor-default"
|
||||
/>
|
||||
|
||||
{/* Mobile: bottom sheet */}
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Quick cart"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 38, mass: 0.9 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 90 || info.velocity.y > 480) onClose();
|
||||
}}
|
||||
className="lg:hidden fixed inset-x-0 bottom-0 z-[61] max-h-[88vh] rounded-t-[28px] bg-[#FAF6EA] shadow-[0_-24px_60px_-12px_rgba(26,77,46,0.35)] flex flex-col"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing">
|
||||
<div className="h-1.5 w-12 rounded-full bg-stone-300/70" />
|
||||
</div>
|
||||
|
||||
<SheetContent
|
||||
displayName={displayName}
|
||||
addedAt={addedAt}
|
||||
headerLabel={headerLabel}
|
||||
cart={cart}
|
||||
subtotal={subtotal}
|
||||
selectedStop={selectedStop}
|
||||
onViewCart={handleViewCart}
|
||||
onCheckout={handleCheckout}
|
||||
onContinue={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop: right-side drawer */}
|
||||
<motion.aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Quick cart"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 36, mass: 0.9 }}
|
||||
className="hidden lg:flex fixed inset-y-0 right-0 z-[61] w-full sm:w-[420px] xl:w-[460px] bg-[#FAF6EA] shadow-[-24px_0_60px_-12px_rgba(26,77,46,0.35)] flex-col"
|
||||
>
|
||||
<SheetContent
|
||||
displayName={displayName}
|
||||
addedAt={addedAt}
|
||||
headerLabel={headerLabel}
|
||||
cart={cart}
|
||||
subtotal={subtotal}
|
||||
selectedStop={selectedStop}
|
||||
onViewCart={handleViewCart}
|
||||
onCheckout={handleCheckout}
|
||||
onContinue={onClose}
|
||||
showCloseButton
|
||||
/>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type CartRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
fulfillment?: "pickup" | "ship";
|
||||
};
|
||||
|
||||
type StopInfo = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
type SheetContentProps = {
|
||||
displayName: string;
|
||||
addedAt: Date;
|
||||
headerLabel: string;
|
||||
cart: CartRow[];
|
||||
subtotal: number;
|
||||
selectedStop: StopInfo | null;
|
||||
onViewCart: () => void;
|
||||
onCheckout: () => void;
|
||||
onContinue: () => void;
|
||||
showCloseButton?: boolean;
|
||||
};
|
||||
|
||||
function SheetContent({
|
||||
displayName,
|
||||
addedAt,
|
||||
headerLabel,
|
||||
cart,
|
||||
subtotal,
|
||||
selectedStop,
|
||||
onViewCart,
|
||||
onCheckout,
|
||||
onContinue,
|
||||
showCloseButton,
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="relative px-6 sm:px-8 pt-2 pb-5 border-b border-stone-900/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -45 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1A4D2E] text-white shadow-[0_4px_14px_rgba(26,77,46,0.4)]"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.22em] text-[#1A4D2E]/70">
|
||||
{headerLabel}
|
||||
</p>
|
||||
<h2 className="font-display text-[22px] sm:text-[24px] font-bold leading-[1.1] text-stone-950 truncate">
|
||||
{displayName}
|
||||
</h2>
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onContinue}
|
||||
aria-label="Close"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-stone-500 hover:bg-stone-900/5 hover:text-stone-900 transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
{addedAt.toLocaleString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})} · Today
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body — scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-8 py-5">
|
||||
<ul className="space-y-3">
|
||||
{cart.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-start gap-4 rounded-2xl bg-white/70 ring-1 ring-stone-900/8 p-4"
|
||||
>
|
||||
{/* Tiny product swatch */}
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-gradient-to-br from-[#FFD86A] via-[#F0B81C] to-[#A47A0B] ring-1 ring-stone-900/10">
|
||||
<CornGlyph className="absolute inset-0 m-auto h-9 w-9 text-[#1A4D2E]/85" />
|
||||
<span className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-[#1A4D2E] text-[10px] font-bold text-white shadow-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-stone-950 text-[15px] leading-tight truncate">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
|
||||
{item.fulfillment === "ship" ? "Ships to door" : "Pickup at stop"}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[15px] font-bold text-stone-950">
|
||||
{item.price}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Selected stop banner (if any) */}
|
||||
{selectedStop && (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-dashed border-[#1A4D2E]/40 bg-[#1A4D2E]/5 p-3.5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-[#1A4D2E]/70">Pickup at</p>
|
||||
<p className="text-sm font-semibold text-stone-950 truncate">
|
||||
{selectedStop.city}, {selectedStop.state}
|
||||
</p>
|
||||
<p className="text-[11px] text-stone-600">
|
||||
{formatDate(selectedStop.date)} · {selectedStop.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtotal */}
|
||||
<div className="mt-5 flex items-baseline justify-between border-t border-stone-900/10 pt-4">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
Subtotal
|
||||
</span>
|
||||
<span className="font-display text-2xl font-bold text-stone-950">
|
||||
${subtotal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-stone-500 text-right">
|
||||
+ tax & shipping at checkout
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer — sticky CTA + express */}
|
||||
<div className="border-t border-stone-900/10 bg-[#F5EFD9] px-6 sm:px-8 pt-5 pb-7 space-y-3">
|
||||
{/* Express checkout row — the buttons are visual shortcuts.
|
||||
/checkout auto-renders Stripe's <ExpressCheckoutElement> for
|
||||
Apple Pay / Google Pay / Link + <PaymentElement> for card via
|
||||
StripeExpressCheckout. The `?express=` query is decorative. */}
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.22em] text-stone-500 mb-2 text-center">
|
||||
Express checkout
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<ExpressButton
|
||||
href="/checkout?express=apple_pay"
|
||||
onClick={onCheckout}
|
||||
label="Pay"
|
||||
icon={<ApplePayMark />}
|
||||
className="bg-black text-white hover:bg-stone-900"
|
||||
/>
|
||||
<ExpressButton
|
||||
href="/checkout?express=google_pay"
|
||||
onClick={onCheckout}
|
||||
label="Pay"
|
||||
icon={<GooglePayMark />}
|
||||
className="bg-white text-stone-900 ring-1 ring-stone-900/10 hover:bg-stone-50"
|
||||
/>
|
||||
<ExpressButton
|
||||
href="/checkout?express=shop_pay"
|
||||
onClick={onCheckout}
|
||||
label="Shop"
|
||||
icon={<ShopPayMark />}
|
||||
className="bg-[#5A31F4] text-white hover:bg-[#4A22E0]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary checkout */}
|
||||
<Link
|
||||
href="/checkout"
|
||||
onClick={onCheckout}
|
||||
className="group flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-[#1A4D2E] text-white font-bold tracking-wide shadow-[0_10px_24px_-8px_rgba(26,77,46,0.5)] hover:bg-[#143C24] active:scale-[0.99] transition-all"
|
||||
>
|
||||
Checkout · ${subtotal.toFixed(2)}
|
||||
<svg className="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{/* Secondary actions */}
|
||||
<div className="flex items-center justify-between pt-1 text-[13px]">
|
||||
<Link
|
||||
href="/cart"
|
||||
onClick={onViewCart}
|
||||
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
View full cart
|
||||
</Link>
|
||||
<button
|
||||
onClick={onContinue}
|
||||
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
Continue shopping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressButton({
|
||||
href,
|
||||
onClick,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`group flex h-12 items-center justify-center gap-1.5 rounded-xl font-bold text-[13px] tracking-tight transition-all active:scale-[0.97] ${className}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Express brand marks (stylized, not official) ─────────────────── */
|
||||
|
||||
function ApplePayMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 14" className="h-4 w-auto" aria-hidden="true">
|
||||
<text x="0" y="11" fontFamily="system-ui, -apple-system, sans-serif" fontWeight="700" fontSize="12" fill="currentColor" letterSpacing="-0.5">
|
||||
Pay
|
||||
</text>
|
||||
<path
|
||||
d="M5.5 3.2c.5-.6.8-1.4.7-2.2-.7 0-1.5.5-2 1.1-.4.5-.8 1.3-.7 2.1.8 0 1.6-.4 2-1zm.7.9c-1.1 0-2 .6-2.6.6-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.8C-2.9 8.1-2 11 1 11c.8 0 1.4-.5 2.3-.5s1.4.5 2.3.5c1 0 1.6-.8 2.2-1.6.7-1 .9-1.9.9-2-.1 0-1.7-.6-1.7-2.4 0-1.5 1.2-2.2 1.3-2.2-.7-1.1-1.9-1.2-2.3-1.2z"
|
||||
transform="translate(0, -3) scale(0.5)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GooglePayMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 36 16" className="h-3.5 w-auto" aria-hidden="true">
|
||||
<text
|
||||
x="0"
|
||||
y="12"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontWeight="600"
|
||||
fontSize="11"
|
||||
fill="currentColor"
|
||||
letterSpacing="-0.3"
|
||||
>
|
||||
G
|
||||
<tspan dx="0.5" fontWeight="400" fontStyle="italic">
|
||||
Pay
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShopPayMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 38 14" className="h-3.5 w-auto" aria-hidden="true">
|
||||
<text
|
||||
x="0"
|
||||
y="11"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontWeight="700"
|
||||
fontSize="11"
|
||||
fill="currentColor"
|
||||
letterSpacing="-0.4"
|
||||
>
|
||||
shop
|
||||
<tspan fontWeight="400">Pay</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CornGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" className={className} aria-hidden="true">
|
||||
{/* Husk leaves */}
|
||||
<path
|
||||
d="M9 16c-2-4-2-9 0-12 1 3 3 5 5 6"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<path
|
||||
d="M23 16c2-4 2-9 0-12-1 3-3 5-5 6"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
{/* Cob */}
|
||||
<ellipse cx="16" cy="17" rx="6" ry="9" fill="currentColor" />
|
||||
{/* Kernels (dots) */}
|
||||
<g fill="#FAF6EA" opacity="0.7">
|
||||
<circle cx="14" cy="12" r="0.8" />
|
||||
<circle cx="18" cy="12" r="0.8" />
|
||||
<circle cx="13" cy="15" r="0.8" />
|
||||
<circle cx="16" cy="14" r="0.8" />
|
||||
<circle cx="19" cy="15" r="0.8" />
|
||||
<circle cx="14" cy="18" r="0.8" />
|
||||
<circle cx="18" cy="18" r="0.8" />
|
||||
<circle cx="16" cy="20" r="0.8" />
|
||||
<circle cx="14" cy="22" r="0.8" />
|
||||
<circle cx="18" cy="22" r="0.8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js";
|
||||
import { getStripe, hasStripePublishableKey } from "@/lib/stripe-client";
|
||||
import { createRetailPaymentIntent } from "@/actions/billing/retail-payment-intent";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
export type CheckoutItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string; // dollars, with or without $ sign
|
||||
quantity: number;
|
||||
fulfillment?: "pickup" | "ship";
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: CheckoutItem[];
|
||||
brandId: string | null;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
selectedStop: Stop | null;
|
||||
/** Required shipping address fields (only used when items include ship fulfillment). */
|
||||
shippingState?: string;
|
||||
shippingPostal?: string;
|
||||
shippingCity?: string;
|
||||
/** Called after the user submits via the form submit button (hosted checkout fallback). */
|
||||
onHostedCheckout?: () => void;
|
||||
/** Stable id used to group intents for the same logical checkout (UUID per page load). */
|
||||
checkoutSessionKey?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the embedded Stripe Elements (Express Checkout + Payment Element)
|
||||
* on top of the existing hosted Stripe Checkout fallback.
|
||||
*
|
||||
* Express Checkout Element is the primary CTA — it surfaces Apple Pay /
|
||||
* Google Pay / Link / PayPal buttons automatically based on the user's
|
||||
* browser and wallet availability. Tapping one opens the wallet's
|
||||
* payment sheet, confirms the PaymentIntent in-page, and we navigate
|
||||
* to /checkout/success to create the order.
|
||||
*
|
||||
* If Stripe.js can't load (no publishable key, network error, etc.),
|
||||
* the component renders a graceful error and the parent falls back to
|
||||
* the hosted Stripe Checkout button.
|
||||
*/
|
||||
export default function StripeExpressCheckout(props: Props) {
|
||||
const router = useRouter();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
|
||||
const [intentAmount, setIntentAmount] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [available, setAvailable] = useState(false);
|
||||
|
||||
const hasShip = props.items.some((i) => i.fulfillment === "ship");
|
||||
const hasPickup = props.items.some(
|
||||
(i) => (i.fulfillment ?? "pickup") === "pickup" && (i as { pickup_type?: string }).pickup_type !== "shed"
|
||||
);
|
||||
|
||||
// Block express checkout if pickup items exist but no stop is selected
|
||||
const stopBlocked = hasPickup && !props.selectedStop;
|
||||
const emailBlocked = !props.customerEmail.trim();
|
||||
|
||||
// Stable key for grouping intents by checkout session (in case of retry)
|
||||
const sessionKey = useMemo(
|
||||
() => props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
[props.checkoutSessionKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!hasStripePublishableKey()) {
|
||||
setAvailable(false);
|
||||
setError("Stripe publishable key not configured. Use the secure checkout button below.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.items.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopBlocked) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await createRetailPaymentIntent(
|
||||
props.items.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
{
|
||||
name: props.customerName,
|
||||
email: props.customerEmail,
|
||||
},
|
||||
props.brandId,
|
||||
props.selectedStop?.id ?? null,
|
||||
hasShip
|
||||
? {
|
||||
state: props.shippingState,
|
||||
postal_code: props.shippingPostal,
|
||||
city: props.shippingCity,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
if (result.success) {
|
||||
setClientSecret(result.clientSecret);
|
||||
setPaymentIntentId(result.paymentIntentId);
|
||||
setIntentAmount(result.amount);
|
||||
setAvailable(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setAvailable(false);
|
||||
setError(result.error);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Re-fetch when the inputs that affect the PaymentIntent change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
sessionKey,
|
||||
props.brandId,
|
||||
props.selectedStop?.id,
|
||||
props.customerName,
|
||||
props.customerEmail,
|
||||
hasShip ? `${props.shippingState}|${props.shippingPostal}|${props.shippingCity}` : "",
|
||||
stopBlocked,
|
||||
]);
|
||||
|
||||
// Persist the pending-checkout payload to sessionStorage so the
|
||||
// success page can create the order. The wallet may overwrite the
|
||||
// name/email (Apple Pay provides them), so we read from the PaymentIntent
|
||||
// metadata on the success page where appropriate.
|
||||
function persistPendingCheckout(intentId: string) {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
sessionStorage.setItem(
|
||||
"pending_checkout",
|
||||
JSON.stringify({
|
||||
customerName: props.customerName,
|
||||
customerEmail: props.customerEmail,
|
||||
customerPhone: props.customerPhone,
|
||||
stopId: props.selectedStop?.id ?? null,
|
||||
items: props.items.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
|
||||
quantity: i.quantity,
|
||||
fulfillment: (i.fulfillment ?? "pickup") as "pickup" | "ship",
|
||||
})),
|
||||
cartBrandId: props.brandId,
|
||||
shippingAddress: hasShip
|
||||
? {
|
||||
state: props.shippingState,
|
||||
postal_code: props.shippingPostal,
|
||||
city: props.shippingCity,
|
||||
}
|
||||
: undefined,
|
||||
idempotencyKey: sessionKey,
|
||||
paymentIntentId: intentId,
|
||||
paymentIntentAmount: intentAmount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Stripe.js loader promise
|
||||
const stripePromise = useMemo(() => getStripe(), []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ExpressShell>
|
||||
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-stone-300 border-t-stone-700 animate-spin" />
|
||||
Preparing express checkout…
|
||||
</div>
|
||||
</ExpressShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!available || !clientSecret || !stripePromise) {
|
||||
return (
|
||||
<ExpressShell>
|
||||
{error && (
|
||||
<p className="text-xs text-stone-500 mb-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onHostedCheckout}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-stone-900 to-stone-700 px-6 py-3.5 text-sm font-semibold text-white hover:from-stone-800 hover:to-stone-600 transition-all shadow-lg shadow-stone-900/20"
|
||||
>
|
||||
Continue to secure checkout →
|
||||
</button>
|
||||
</ExpressShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpressShell>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: "flat",
|
||||
variables: {
|
||||
colorPrimary: "#1A4D2E",
|
||||
colorBackground: "#ffffff",
|
||||
colorText: "#0a0a0a",
|
||||
colorDanger: "#dc2626",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
spacingUnit: "4px",
|
||||
borderRadius: "12px",
|
||||
},
|
||||
rules: {
|
||||
".Input": { boxShadow: "none", border: "1px solid #e7e5e4" },
|
||||
".Input:focus": { border: "1px solid #1A4D2E" },
|
||||
".Label": { fontWeight: "600", color: "#0a0a0a" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CheckoutInner
|
||||
{...props}
|
||||
emailBlocked={emailBlocked}
|
||||
stopBlocked={stopBlocked}
|
||||
paymentIntentId={paymentIntentId}
|
||||
intentAmount={intentAmount}
|
||||
persistPendingCheckout={persistPendingCheckout}
|
||||
onError={setError}
|
||||
onSuccess={() => {
|
||||
// Express / Card confirmed in-page. Navigate to success.
|
||||
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
|
||||
}}
|
||||
onHostedCheckout={props.onHostedCheckout}
|
||||
/>
|
||||
</Elements>
|
||||
</ExpressShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg className="h-4 w-4 text-stone-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-700">
|
||||
Express checkout
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type InnerProps = Props & {
|
||||
emailBlocked: boolean;
|
||||
stopBlocked: boolean;
|
||||
paymentIntentId: string | null;
|
||||
intentAmount: number | null;
|
||||
persistPendingCheckout: (intentId: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
function CheckoutInner({
|
||||
items,
|
||||
brandId,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
selectedStop,
|
||||
shippingState,
|
||||
shippingPostal,
|
||||
shippingCity,
|
||||
emailBlocked,
|
||||
stopBlocked,
|
||||
paymentIntentId,
|
||||
persistPendingCheckout,
|
||||
onError,
|
||||
onSuccess,
|
||||
onHostedCheckout,
|
||||
}: InnerProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [expressReady, setExpressReady] = useState(false);
|
||||
|
||||
const blocked = emailBlocked || stopBlocked;
|
||||
const siteUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||||
|
||||
async function handleExpressConfirm(event: StripeExpressCheckoutElementConfirmEvent) {
|
||||
if (blocked) {
|
||||
event.paymentFailed({ reason: 'fail' });
|
||||
onError(
|
||||
emailBlocked
|
||||
? "Enter your email above before paying."
|
||||
: "Pick a pickup stop before paying."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripe || !elements) {
|
||||
event.paymentFailed({ reason: 'fail' });
|
||||
onError("Stripe is not ready yet. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the pending checkout payload so the success page can
|
||||
// create the order. We do this BEFORE confirmPayment because the
|
||||
// wallet may navigate away briefly during the payment sheet.
|
||||
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
|
||||
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${siteUrl}/checkout/success?source=express`,
|
||||
payment_method_data: {
|
||||
billing_details: {
|
||||
name: customerName || undefined,
|
||||
email: customerEmail || undefined,
|
||||
phone: customerPhone || undefined,
|
||||
address: shippingState
|
||||
? {
|
||||
state: shippingState,
|
||||
postal_code: shippingPostal,
|
||||
city: shippingCity,
|
||||
country: "US",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmError) {
|
||||
event.paymentFailed({ reason: 'fail' });
|
||||
onError(confirmError.message ?? "Payment failed. Please try again.");
|
||||
} else {
|
||||
// payment confirmed in-page — wallet closes its sheet automatically.
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCardSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
onError("Stripe is not ready yet. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (blocked) {
|
||||
onError(
|
||||
emailBlocked
|
||||
? "Enter your email above before paying."
|
||||
: "Pick a pickup stop before paying."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
onError(submitError.message ?? "Form is incomplete.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${siteUrl}/checkout/success?source=card`,
|
||||
payment_method_data: {
|
||||
billing_details: {
|
||||
name: customerName || undefined,
|
||||
email: customerEmail || undefined,
|
||||
phone: customerPhone || undefined,
|
||||
address: shippingState
|
||||
? {
|
||||
state: shippingState,
|
||||
postal_code: shippingPostal,
|
||||
city: shippingCity,
|
||||
country: "US",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmError) {
|
||||
onError(confirmError.message ?? "Payment failed.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Express (Apple Pay, Google Pay, Link, PayPal) */}
|
||||
<div>
|
||||
<ExpressCheckoutElement
|
||||
onConfirm={handleExpressConfirm}
|
||||
onReady={(ev) => {
|
||||
const m = ev.availablePaymentMethods;
|
||||
const any = !!m && (m.applePay || m.googlePay || m.link || m.paypal || m.amazonPay);
|
||||
setExpressReady(any);
|
||||
}}
|
||||
options={{
|
||||
buttonType: { applePay: "buy", googlePay: "buy" },
|
||||
buttonTheme: { applePay: "black", googlePay: "black" },
|
||||
paymentMethodOrder: ["apple_pay", "google_pay", "link", "paypal"],
|
||||
layout: { maxColumns: 3, maxRows: 1 },
|
||||
}}
|
||||
/>
|
||||
{!expressReady && !blocked && (
|
||||
<p className="mt-2 text-[11px] text-stone-500 text-center">
|
||||
No express wallets detected on this device — you can still pay by card below.
|
||||
</p>
|
||||
)}
|
||||
{blocked && (
|
||||
<p className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-center">
|
||||
{emailBlocked
|
||||
? "Enter your email above to enable express checkout."
|
||||
: "Pick a pickup stop above to enable express checkout."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-stone-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-3 text-[10px] font-bold uppercase tracking-widest text-stone-400">
|
||||
or pay by card
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card form */}
|
||||
<form onSubmit={handleCardSubmit}>
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: "tabs",
|
||||
fields: { billingDetails: "auto" },
|
||||
wallets: { applePay: "never", googlePay: "never" },
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || !elements || submitting || blocked}
|
||||
className="mt-4 w-full rounded-xl bg-stone-900 hover:bg-stone-800 disabled:bg-stone-400 disabled:cursor-not-allowed px-6 py-3.5 text-sm font-semibold text-white transition-all shadow-lg shadow-stone-900/15"
|
||||
>
|
||||
{submitting ? "Processing…" : "Pay by card"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Fallback to hosted checkout (existing flow) */}
|
||||
{onHostedCheckout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHostedCheckout}
|
||||
className="w-full text-xs text-stone-500 hover:text-stone-800 underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Having trouble? Use the secure hosted checkout instead.
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence, useScroll, useTransform } from "framer-motion";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import QuickCartSheet from "./QuickCartSheet";
|
||||
import StorefrontHeader from "./StorefrontHeader";
|
||||
import StorefrontFooter from "./StorefrontFooter";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
logoUrl?: string | null;
|
||||
logoUrlDark?: string | null;
|
||||
};
|
||||
|
||||
const PRODUCT = {
|
||||
id: "sweet-corn-box-12",
|
||||
slug: "sweet-corn-box",
|
||||
name: "Fresh Sweet Corn Box – 12 Ears",
|
||||
tagline: "Straight-from-the-farm sweetness in every bite.",
|
||||
price: "$29.99",
|
||||
priceNumeric: 29.99,
|
||||
// Used by the existing /cart flow to filter pickup vs ship
|
||||
type: "Pickup & Shipping",
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1600&q=80",
|
||||
fallbackGradient:
|
||||
"radial-gradient(ellipse at 50% 50%, #FFE08A 0%, #E5A80F 35%, #8A5A06 80%, #1A4D2E 100%)",
|
||||
};
|
||||
|
||||
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
export default function SweetCornProductPage({ brandId, brandName, logoUrl, logoUrlDark }: Props) {
|
||||
const router = useRouter();
|
||||
const { addToCart, buyNow } = useCart();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
const [buyNowPulse, setBuyNowPulse] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// Parallax for the hero corn ears floating decoration
|
||||
const { scrollY } = useScroll();
|
||||
const floatY1 = useTransform(scrollY, [0, 800], [0, -120]);
|
||||
const floatY2 = useTransform(scrollY, [0, 800], [0, 80]);
|
||||
|
||||
// Auto-dismiss the "Added" pulse after a short delay
|
||||
useEffect(() => {
|
||||
if (!justAdded) return;
|
||||
const t = setTimeout(() => setJustAdded(false), 1800);
|
||||
return () => clearTimeout(t);
|
||||
}, [justAdded]);
|
||||
|
||||
function buildBaseItem() {
|
||||
return {
|
||||
id: PRODUCT.id,
|
||||
name: PRODUCT.name,
|
||||
price: PRODUCT.price,
|
||||
brand_id: brandId,
|
||||
brand_slug: "tuxedo",
|
||||
is_taxable: true,
|
||||
pickup_type: "scheduled_stop" as const,
|
||||
description: PRODUCT.tagline,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddToCart() {
|
||||
addToCart(buildBaseItem(), "pickup");
|
||||
setJustAdded(true);
|
||||
// Tiny delay so the user sees the "Added" state before the sheet slides up
|
||||
setTimeout(() => setSheetOpen(true), 280);
|
||||
}
|
||||
|
||||
function handleBuyNow() {
|
||||
buyNow(buildBaseItem(), "pickup");
|
||||
setBuyNowPulse(true);
|
||||
setTimeout(() => router.push("/checkout"), 140);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FAF6EA] text-stone-950">
|
||||
<StorefrontHeader
|
||||
brandName={brandName}
|
||||
brandSlug="tuxedo"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
showWholesaleLink
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<main>
|
||||
{/* ── HERO ─────────────────────────────────────────────────────── */}
|
||||
<section className="relative overflow-hidden bg-[#FAF6EA]">
|
||||
{/* Subtle paper grain */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.035] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.7'/></svg>\")",
|
||||
}}
|
||||
/>
|
||||
{/* Decorative giant "12" in the background */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-20 -right-10 lg:-right-24 font-display text-[260px] lg:text-[420px] font-black leading-none text-[#E5A80F]/8 select-none pointer-events-none"
|
||||
style={{ color: "rgba(229, 168, 15, 0.08)" }}
|
||||
>
|
||||
12
|
||||
</div>
|
||||
|
||||
{/* Floating decorative corn ears */}
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ y: floatY1 }}
|
||||
className="absolute top-32 right-[6%] hidden lg:block"
|
||||
>
|
||||
<FloatingCorn className="w-16 text-[#1A4D2E]/15 rotate-12" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ y: floatY2 }}
|
||||
className="absolute bottom-20 left-[4%] hidden lg:block"
|
||||
>
|
||||
<FloatingCorn className="w-12 text-[#E5A80F]/25 -rotate-12" />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 pt-10 sm:pt-14 lg:pt-20 pb-8 lg:pb-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
|
||||
<Link href="/tuxedo" className="hover:text-stone-900 transition-colors">
|
||||
Tuxedo Corn
|
||||
</Link>
|
||||
<Chevron />
|
||||
<Link href="/tuxedo#products" className="hover:text-stone-900 transition-colors">
|
||||
Shop
|
||||
</Link>
|
||||
<Chevron />
|
||||
<span className="text-stone-900">Sweet Corn Box</span>
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 lg:mt-12 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14 items-start">
|
||||
{/* LEFT — Visual */}
|
||||
<div className="lg:col-span-7 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: EASE_OUT }}
|
||||
className="relative aspect-[4/3] sm:aspect-[5/4] lg:aspect-[6/5] w-full overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]"
|
||||
>
|
||||
{/* Gradient fallback (always behind, visible until image loads or on error) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: PRODUCT.fallbackGradient }}
|
||||
/>
|
||||
{/* Decorative corn cobs inside the image frame */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CornIllustration className="w-3/5 max-w-[420px] text-[#1A4D2E]/55" />
|
||||
</div>
|
||||
{!imgError && (
|
||||
<Image
|
||||
src={PRODUCT.imageUrl}
|
||||
alt="Freshly harvested Olathe Sweet sweet corn"
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className={`object-cover transition-opacity duration-700 ${
|
||||
imgLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
{/* Bottom scrim for badge legibility */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/35 to-transparent pointer-events-none" />
|
||||
{/* Tilted corner stamp */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, rotate: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, rotate: -8, scale: 1 }}
|
||||
transition={{ delay: 0.55, type: "spring", stiffness: 200, damping: 14 }}
|
||||
className="absolute top-5 left-5 lg:top-7 lg:left-7 origin-top-left"
|
||||
>
|
||||
<div className="flex h-[88px] w-[88px] lg:h-[100px] lg:w-[100px] flex-col items-center justify-center rounded-full border-2 border-dashed border-white/85 bg-[#1A4D2E]/90 text-white text-center shadow-lg backdrop-blur-sm">
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
|
||||
Peak
|
||||
</span>
|
||||
<span className="font-display text-[20px] lg:text-[22px] font-black leading-none">
|
||||
SEASON
|
||||
</span>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
|
||||
2026
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Right corner "12 ears" badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="absolute bottom-5 right-5 lg:bottom-7 lg:right-7"
|
||||
>
|
||||
<div className="rounded-2xl bg-white/95 px-3.5 py-2 ring-1 ring-stone-900/10 shadow-lg backdrop-blur-sm">
|
||||
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
|
||||
Box contains
|
||||
</p>
|
||||
<p className="font-display text-[22px] font-black text-stone-950 leading-none mt-0.5">
|
||||
12 ears
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Thumbnails row (decorative — same image) */}
|
||||
<div className="mt-4 flex gap-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`View image ${i + 1}`}
|
||||
className={`relative aspect-square w-16 sm:w-20 overflow-hidden rounded-xl ring-2 transition-all ${
|
||||
i === 0
|
||||
? "ring-[#1A4D2E]"
|
||||
: "ring-stone-900/10 hover:ring-stone-900/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: PRODUCT.fallbackGradient }}
|
||||
/>
|
||||
<CornIllustration className="absolute inset-0 m-auto w-3/5 text-[#1A4D2E]/55" />
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto hidden sm:flex items-center gap-1.5 rounded-xl bg-stone-900/5 px-3 py-2 text-[10px] font-mono uppercase tracking-widest text-stone-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Lot 04-26 · In season
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Copy + Buy Box */}
|
||||
<div className="lg:col-span-5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: EASE_OUT }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#1A4D2E] px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#FCD34D] animate-pulse" />
|
||||
Ships fresh
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
Same-day harvest when possible
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 font-display text-[44px] sm:text-[52px] lg:text-[60px] font-black leading-[0.95] tracking-[-0.02em] text-stone-950">
|
||||
<span className="block">Sweet Corn</span>
|
||||
<span className="block italic font-normal text-[#1A4D2E]">Box</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 font-display text-[19px] sm:text-[21px] leading-[1.35] text-stone-700 italic">
|
||||
{PRODUCT.tagline}
|
||||
</p>
|
||||
|
||||
<p className="mt-5 text-[15px] leading-relaxed text-stone-700 max-w-prose">
|
||||
Our 12-Ear Corn Box is packed with peak-season, super-sweet corn picked
|
||||
that morning and rushed to your door. Perfect for BBQs, family dinners,
|
||||
or freezing for winter.
|
||||
</p>
|
||||
|
||||
{/* Why our corn (inline) */}
|
||||
<ul className="mt-6 space-y-2.5">
|
||||
{WHY_CORN.map((item) => (
|
||||
<li key={item.title} className="flex items-start gap-3">
|
||||
<span className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-bold text-stone-950 text-[14px]">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-stone-600 text-[14px]">
|
||||
{item.body && <> — {item.body}</>}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Price + buy box (desktop sticky) */}
|
||||
<div
|
||||
id="buy-box"
|
||||
className="mt-8 lg:mt-10 rounded-3xl bg-white ring-1 ring-stone-900/10 p-6 sm:p-7 shadow-[0_20px_40px_-12px_rgba(26,77,46,0.18)]"
|
||||
>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
One box · 12 ears
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[44px] font-black leading-none tracking-tight text-stone-950">
|
||||
{PRODUCT.price}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-stone-500">
|
||||
≈ $2.50 / ear · tax included
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
In stock
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[20px] font-bold text-[#1A4D2E]">
|
||||
240 boxes
|
||||
</p>
|
||||
<p className="text-[10px] text-stone-500">ready this week</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
Qty
|
||||
</span>
|
||||
<div className="inline-flex items-center rounded-full ring-1 ring-stone-900/15 bg-stone-50">
|
||||
<button
|
||||
aria-label="Decrease quantity"
|
||||
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-l-full transition-colors"
|
||||
onClick={() => {
|
||||
/* single-product page — qty is always 1 for now */
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-bold text-stone-950">1</span>
|
||||
<button
|
||||
aria-label="Increase quantity"
|
||||
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-r-full transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="ml-auto text-[12px] text-stone-500 hidden sm:inline">
|
||||
Ships in a compostable cooler
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart (primary) */}
|
||||
<motion.button
|
||||
onClick={handleAddToCart}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`mt-5 relative w-full overflow-hidden rounded-2xl h-14 sm:h-16 font-bold text-[15px] sm:text-[16px] tracking-wide transition-all ${
|
||||
justAdded
|
||||
? "bg-[#1A4D2E] text-white"
|
||||
: "bg-stone-950 text-white hover:bg-stone-800"
|
||||
}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{justAdded ? (
|
||||
<motion.span
|
||||
key="added"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Added to cart
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="add"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
Add Box to Cart · {PRODUCT.price}
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
|
||||
{/* Buy Now (secondary, urgent orange) */}
|
||||
<motion.button
|
||||
onClick={handleBuyNow}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`mt-3 group relative w-full overflow-hidden rounded-2xl h-12 sm:h-[52px] font-bold text-[14px] tracking-wide transition-all ${
|
||||
buyNowPulse
|
||||
? "bg-[#D97706] text-white"
|
||||
: "bg-[#FFF1D6] text-[#9A4A06] ring-1 ring-[#E5A80F]/40 hover:bg-[#FFE7B0]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" />
|
||||
</svg>
|
||||
Quick Buy — Ships Today
|
||||
<span className="ml-1 font-mono text-[10px] font-medium uppercase tracking-widest opacity-70 hidden sm:inline">
|
||||
1-click
|
||||
</span>
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
{/* Express row (visual only) */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<ExpressChip label="Apple Pay" />
|
||||
<ExpressChip label="Google Pay" />
|
||||
<ExpressChip label="Shop Pay" />
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-[11px] text-stone-500">
|
||||
One click. One box. Farm-fresh delivered.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── GUARANTEE / TRUST STRIP ─────────────────────────────────────── */}
|
||||
<section className="relative bg-[#1A4D2E] text-white overflow-hidden">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at 20% 50%, rgba(252, 211, 77, 0.4) 0%, transparent 50%), radial-gradient(ellipse at 80% 50%, rgba(252, 211, 77, 0.3) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-12 sm:py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
||||
<TrustColumn
|
||||
icon={<TruckIcon />}
|
||||
eyebrow="Ships Fresh"
|
||||
title="Same-day harvest when possible"
|
||||
body="We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that morning."
|
||||
/>
|
||||
<TrustColumn
|
||||
icon={<ShieldIcon />}
|
||||
eyebrow="Satisfaction"
|
||||
title="100% Sweetness Guarantee"
|
||||
body="If it’s not the sweetest corn you’ve ever had, we’ll replace it. No questions, no forms, no fuss."
|
||||
accent
|
||||
/>
|
||||
<TrustColumn
|
||||
icon={<SparkleIcon />}
|
||||
eyebrow="Versatile"
|
||||
title="Great for every summer table"
|
||||
body="Grilling, boiling, freezing, or corn-on-the-cob parties — twelve ears is exactly the right amount for a family of four to share."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FARM STORY ─────────────────────────────────────────────────── */}
|
||||
<section className="relative bg-[#F3EDD8]">
|
||||
<div className="mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-20 sm:py-28">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
|
||||
<div className="lg:col-span-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06]">
|
||||
Field notes · Vol. IV
|
||||
</p>
|
||||
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] lg:text-[52px] font-black leading-[1.02] tracking-tight text-stone-950">
|
||||
Picked by hand, <span className="italic font-normal text-[#1A4D2E]">stayed</span> sweet for days.
|
||||
</h2>
|
||||
<p className="mt-5 text-stone-700 text-[16px] leading-relaxed max-w-prose">
|
||||
We hand-select every ear for size and quality. No machine knows when a
|
||||
cob is at peak sugar content. That’s why your box is a little heavier
|
||||
than you expect — and a whole lot sweeter than any corn you’ve bought
|
||||
from a grocery store shelf.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-6">
|
||||
<Stat number="40+" label="Years growing" />
|
||||
<span className="h-10 w-px bg-stone-900/15" />
|
||||
<Stat number="3" label="Generations" />
|
||||
<span className="h-10 w-px bg-stone-900/15" />
|
||||
<Stat number="100%" label="Hand-picked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7 relative">
|
||||
<div className="relative aspect-[5/4] overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #FCD34D 0%, #E5A80F 45%, #8A5A06 100%)",
|
||||
}}
|
||||
/>
|
||||
{/* Abstract corn field lines */}
|
||||
<svg
|
||||
viewBox="0 0 400 320"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<pattern id="rows" width="50" height="20" patternUnits="userSpaceOnUse" patternTransform="rotate(-8)">
|
||||
<line x1="0" y1="10" x2="50" y2="10" stroke="#1A4D2E" strokeWidth="2" strokeLinecap="round" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="400" height="320" fill="url(#rows)" opacity="0.45" />
|
||||
</svg>
|
||||
{/* Floating cob clusters */}
|
||||
<CornIllustration className="absolute -left-6 top-1/2 -translate-y-1/2 w-32 text-[#1A4D2E]/85 rotate-[-8deg]" />
|
||||
<CornIllustration className="absolute right-6 top-8 w-24 text-[#1A4D2E]/75 rotate-[12deg]" />
|
||||
<CornIllustration className="absolute right-16 bottom-8 w-20 text-[#1A4D2E]/70 -rotate-6" />
|
||||
{/* Sun stamp */}
|
||||
<div className="absolute top-5 right-5 h-20 w-20 rounded-full bg-[#FFE08A] ring-4 ring-[#1A4D2E]/15 flex items-center justify-center">
|
||||
<span className="font-display text-[10px] font-bold text-[#1A4D2E] text-center leading-tight">
|
||||
HAND
|
||||
<br />
|
||||
PICKED
|
||||
</span>
|
||||
</div>
|
||||
{/* Bottom data strip */}
|
||||
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-stone-950/90 backdrop-blur-sm px-4 py-3 flex items-center justify-between text-white text-[11px] font-mono">
|
||||
<div>
|
||||
<span className="text-white/50">FIELD</span> · 04-N
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/50">BRIX</span> · 24.2°
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/50">PICK</span> · 06:14
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ MICRO ──────────────────────────────────────────────────── */}
|
||||
<section className="relative bg-[#FAF6EA]">
|
||||
<div className="mx-auto max-w-4xl px-5 sm:px-6 lg:px-8 py-20 sm:py-24">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06] text-center">
|
||||
Buyer’s notes
|
||||
</p>
|
||||
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] font-black leading-tight text-center text-stone-950">
|
||||
Questions, answered.
|
||||
</h2>
|
||||
<div className="mt-12 divide-y divide-stone-900/10 border-t border-b border-stone-900/10">
|
||||
{FAQS.map((faq) => (
|
||||
<FAQItem key={faq.q} q={faq.q} a={faq.a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FINAL CTA STRIP (desktop) ─────────────────────────────────── */}
|
||||
<section className="hidden lg:block relative bg-stone-950 text-white overflow-hidden">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at 30% 50%, rgba(229, 168, 15, 0.5) 0%, transparent 60%), radial-gradient(ellipse at 80% 50%, rgba(26, 77, 46, 0.6) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative mx-auto max-w-6xl px-8 py-16 flex items-center justify-between gap-10">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#FCD34D]">
|
||||
Ready when you are
|
||||
</p>
|
||||
<h3 className="mt-3 font-display text-[40px] font-black leading-[1.05]">
|
||||
Twelve ears. <span className="italic font-normal">One click.</span>
|
||||
</h3>
|
||||
<p className="mt-2 text-stone-400 max-w-md">
|
||||
Free shipping on orders over $40. Cancel anytime before harvest.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="rounded-2xl h-14 px-7 bg-white text-stone-950 font-bold hover:bg-stone-100 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Add to cart — {PRODUCT.price}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuyNow}
|
||||
className="rounded-2xl h-14 px-7 bg-[#FCD34D] text-stone-950 font-bold hover:bg-[#FBBF24] active:scale-[0.98] transition-all"
|
||||
>
|
||||
Quick Buy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* ── STICKY MOBILE BOTTOM CTA BAR ──────────────────────────────────── */}
|
||||
<div className="lg:hidden fixed inset-x-0 bottom-0 z-40 bg-[#FAF6EA]/95 backdrop-blur-xl border-t border-stone-900/10 shadow-[0_-12px_30px_-12px_rgba(26,77,46,0.3)]">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
|
||||
12-Ear Box
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[26px] font-black leading-none text-stone-950">
|
||||
{PRODUCT.price}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBuyNow}
|
||||
className="h-14 min-w-[120px] rounded-2xl bg-[#FFF1D6] text-[#9A4A06] font-bold text-[14px] ring-1 ring-[#E5A80F]/40 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Quick Buy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="h-14 min-w-[160px] rounded-2xl bg-stone-950 text-white font-bold text-[15px] active:scale-[0.98] transition-all"
|
||||
>
|
||||
Add · {PRODUCT.price}
|
||||
</button>
|
||||
</div>
|
||||
{/* Safe-area spacer for iOS */}
|
||||
<div className="h-[env(safe-area-inset-bottom)]" />
|
||||
</div>
|
||||
|
||||
{/* Bottom padding so sticky bar doesn't cover last content on mobile */}
|
||||
<div className="lg:hidden h-28" aria-hidden />
|
||||
|
||||
<StorefrontFooter
|
||||
brandName={brandName}
|
||||
brandSlug="tuxedo"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<QuickCartSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
productName={PRODUCT.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────────────────────── */
|
||||
|
||||
function TrustColumn({
|
||||
icon,
|
||||
eyebrow,
|
||||
title,
|
||||
body,
|
||||
accent,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
body: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
accent ? "bg-[#FCD34D] text-stone-950" : "bg-white/10 text-[#FCD34D]"
|
||||
} ring-1 ring-white/15`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<p
|
||||
className={`mt-5 font-mono text-[10px] uppercase tracking-[0.25em] ${
|
||||
accent ? "text-[#FCD34D]" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="mt-2 font-display text-[24px] font-bold leading-[1.15] text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-white/75 max-w-sm">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ number, label }: { number: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-[40px] font-black leading-none text-stone-950">
|
||||
{number}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQItem({ q, a }: { q: string; a: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="py-5">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-4 text-left group"
|
||||
>
|
||||
<span className="font-display text-[18px] sm:text-[20px] font-bold text-stone-950 group-hover:text-[#1A4D2E] transition-colors">
|
||||
{q}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-1 ring-stone-900/15 transition-transform duration-300 ${
|
||||
open ? "rotate-45 bg-stone-950 text-white" : "text-stone-700"
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-3 text-[15px] leading-relaxed text-stone-700 max-w-prose">{a}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron() {
|
||||
return (
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressChip({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-9 items-center justify-center rounded-lg bg-stone-100 ring-1 ring-stone-900/5 text-[11px] font-semibold text-stone-700">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TruckIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h11v10H3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14 10h4l3 3v4h-7z" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
<circle cx="17" cy="18" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SparkleIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CornIllustration({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" className={className} aria-hidden="true">
|
||||
{/* Husk leaves */}
|
||||
<path
|
||||
d="M30 70c-12-15-15-40-5-60 5 18 15 30 25 38"
|
||||
fill="currentColor"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M70 70c12-15 15-40 5-60-5 18-15 30-25 38"
|
||||
fill="currentColor"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M50 75c-3-20 0-50 5-65 1 20-1 45-3 60"
|
||||
fill="currentColor"
|
||||
opacity="0.45"
|
||||
/>
|
||||
{/* Cob */}
|
||||
<ellipse cx="50" cy="78" rx="20" ry="34" fill="currentColor" />
|
||||
{/* Kernels */}
|
||||
<g fill="#FAF6EA" opacity="0.75">
|
||||
{Array.from({ length: 8 }).map((_, row) =>
|
||||
Array.from({ length: 5 }).map((_, col) => {
|
||||
const x = 50 - 14 + col * 7 + (row % 2 ? 3.5 : 0);
|
||||
const y = 55 + row * 6;
|
||||
return <circle key={`${row}-${col}`} cx={x} cy={y} r="1.6" />;
|
||||
})
|
||||
)}
|
||||
</g>
|
||||
{/* Silk at top */}
|
||||
<g stroke="currentColor" strokeWidth="1.4" fill="none" opacity="0.7" strokeLinecap="round">
|
||||
<path d="M50 45c-6-8-12-12-18-12" />
|
||||
<path d="M50 45c0-10 4-15 8-20" />
|
||||
<path d="M50 45c6-6 12-8 18-10" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingCorn({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 60 80" className={className} aria-hidden="true">
|
||||
<ellipse cx="30" cy="40" rx="14" ry="24" fill="currentColor" />
|
||||
<g fill="white" opacity="0.3">
|
||||
<circle cx="26" cy="28" r="1.2" />
|
||||
<circle cx="32" cy="32" r="1.2" />
|
||||
<circle cx="28" cy="38" r="1.2" />
|
||||
<circle cx="34" cy="42" r="1.2" />
|
||||
<circle cx="26" cy="48" r="1.2" />
|
||||
<circle cx="32" cy="52" r="1.2" />
|
||||
<circle cx="28" cy="58" r="1.2" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Static data ─────────────────────────────────────────────── */
|
||||
|
||||
const WHY_CORN = [
|
||||
{ title: "Naturally grown & non-GMO", body: "no lab has touched this seed" },
|
||||
{ title: "Harvested at peak sugar content", body: "before the heat can steal a calorie" },
|
||||
{ title: "Hand-selected for size & quality", body: "the only machine we trust is a cooler" },
|
||||
{ title: "Stays sweet for days in the fridge", body: "and freezes beautifully for winter" },
|
||||
];
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "How fast will my box ship?",
|
||||
a: "Same-day harvest when the weather cooperates. You’ll get a tracking link the moment the cooler leaves the farm — typically within 24 hours of your order.",
|
||||
},
|
||||
{
|
||||
q: "Can I pick up at a local stop instead?",
|
||||
a: "Yes — at checkout, choose pickup and select a stop near you. We deliver to scheduled stops in Colorado, Utah, New Mexico, and Arizona through the summer.",
|
||||
},
|
||||
{
|
||||
q: "What if my corn isn’t the sweetest I’ve ever had?",
|
||||
a: "We replace it. Send a photo to hello@tuxedocorn.com and we’ll send a fresh box the next morning. No forms, no questions.",
|
||||
},
|
||||
{
|
||||
q: "How long does it stay fresh?",
|
||||
a: "Seven days in the fridge with the husks on. Three months in the freezer if you blanch it for three minutes first.",
|
||||
},
|
||||
];
|
||||
@@ -20,6 +20,8 @@ type StopInfo = {
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
export type { StopInfo };
|
||||
|
||||
type CartContextType = {
|
||||
cart: CartItem[];
|
||||
subtotal: number;
|
||||
@@ -30,6 +32,12 @@ type CartContextType = {
|
||||
cartRestored: boolean; // true when server cart was loaded (for toast)
|
||||
setSelectedStop: (stop: StopInfo | null) => void;
|
||||
addToCart: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => void;
|
||||
/**
|
||||
* Replaces the cart with a single item (clears other items + selected stop)
|
||||
* and returns the resulting item so the caller can navigate to /checkout
|
||||
* for a true 1-tap "Buy Now" flow.
|
||||
*/
|
||||
buyNow: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => CartItem;
|
||||
increaseQuantity: (id: string) => void;
|
||||
decreaseQuantity: (id: string) => void;
|
||||
removeFromCart: (id: string) => void;
|
||||
@@ -201,6 +209,27 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the entire cart with this single item. Use for "Buy Now" /
|
||||
* "Quick Buy" flows where the buyer wants to skip the cart step and go
|
||||
* straight to checkout. The returned CartItem reflects the final state
|
||||
* (existing items of the same product are merged by quantity).
|
||||
*/
|
||||
function buyNow(item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship"): CartItem {
|
||||
// Always clear selected stop on a buy-now — the buyer is bypassing the
|
||||
// standard stop-picker flow and will pick a stop at checkout.
|
||||
setSelectedStop(null);
|
||||
|
||||
const finalItem: CartItem = {
|
||||
...item,
|
||||
quantity: 1,
|
||||
fulfillment: fulfillment ?? "pickup",
|
||||
};
|
||||
setCart([finalItem]);
|
||||
setJustAdded(finalItem);
|
||||
return finalItem;
|
||||
}
|
||||
|
||||
function increaseQuantity(id: string) {
|
||||
setCart((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
|
||||
@@ -291,6 +320,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
cartRestored: showRestoredToast,
|
||||
setSelectedStop: setSelectedStop,
|
||||
addToCart,
|
||||
buyNow,
|
||||
increaseQuantity,
|
||||
decreaseQuantity,
|
||||
removeFromCart,
|
||||
@@ -306,7 +336,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
export function useCart(): CartContextType {
|
||||
const context = useContext(CartContext);
|
||||
if (!context) throw new Error("useCart must be used inside CartProvider");
|
||||
return context;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// Shared AdminUser type — safe to import from both server and client components
|
||||
//
|
||||
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
|
||||
// `brand_ids` is the full list of brands the admin can act in.
|
||||
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
|
||||
// (resolved by `listBrandsForAdmin`).
|
||||
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
|
||||
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
|
||||
// `brand_ids = [that one]`.
|
||||
export type AdminUser = {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
brand_id: string | null;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
||||
brand_ids: string[];
|
||||
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||
active: boolean;
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
@@ -15,4 +25,4 @@ export type AdminUser = {
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
must_change_password?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Brand-scope helpers for multi-brand admin support.
|
||||
*
|
||||
* Resolution order (documented in
|
||||
* docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md):
|
||||
* 1. URL/explicit `requested` brand id (highest priority)
|
||||
* 2. `active_brand_id` cookie (the persistent "what brand am I in right now")
|
||||
* 3. `adminUser.brand_id` (legacy single-brand fallback)
|
||||
* 4. First of `adminUser.brand_ids`
|
||||
* 5. (platform_admin only) `null` → "all brands"
|
||||
*
|
||||
* For non-platform-admins, the returned brand is validated against
|
||||
* `adminUser.brand_ids` — if `requested` or the cookie brand is not in the
|
||||
* admin's accessible brands, the resolver falls through to a brand the admin
|
||||
* does have access to (silent recovery).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
|
||||
export const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||
|
||||
/**
|
||||
* Resolve the active brand id for the given admin user.
|
||||
*
|
||||
* @param adminUser - The current admin user (must already be loaded).
|
||||
* @param requested - Optional explicit brand id (e.g. from a URL param).
|
||||
* When set and the admin has access, wins over cookie.
|
||||
* @returns The brand id to act in, or `null` for platform_admin "all brands".
|
||||
*/
|
||||
export async function getActiveBrandId(
|
||||
adminUser: AdminUser,
|
||||
requested?: string | null
|
||||
): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||
|
||||
// platform_admin: requested > cookie > null (all brands)
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return requested ?? cookieBrand ?? null;
|
||||
}
|
||||
|
||||
// Non-platform-admin: validate that requested/cookie brands are accessible
|
||||
if (requested && adminUser.brand_ids.includes(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||
return cookieBrand;
|
||||
}
|
||||
|
||||
// Fall back to the legacy single brand, then first of the membership list
|
||||
return adminUser.brand_id ?? adminUser.brand_ids[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the persistent active-brand cookie. Should only be called after
|
||||
* validating the admin has access (use `assertBrandAccess`).
|
||||
*/
|
||||
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active-brand cookie. Used when platform_admin selects
|
||||
* "All brands" (the cookie absence = "no specific brand pinned").
|
||||
*/
|
||||
export async function clearActiveBrandCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the admin user is not a platform_admin and does not have the
|
||||
* given brand in their membership list. Use this for server actions and
|
||||
* API routes that receive a brandId from URL/form/RPC return rather than
|
||||
* `getActiveBrandId`.
|
||||
*/
|
||||
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||
if (adminUser.role === "platform_admin") return;
|
||||
if (!adminUser.brand_ids.includes(brandId)) {
|
||||
throw new Error("Brand access denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Browser-only Stripe.js loader.
|
||||
*
|
||||
* Reads `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and returns a cached
|
||||
* `loadStripe` promise. Never call this from a server component — it
|
||||
* pulls in `@stripe/stripe-js` which depends on `window`.
|
||||
*
|
||||
* The published key is safe to ship to the browser; the secret key
|
||||
* never leaves the server (see `src/actions/billing/retail-checkout.ts`).
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
|
||||
/**
|
||||
* Returns the cached Stripe.js promise, creating it on the first call.
|
||||
* Returns `null` if the publishable key is not configured — callers
|
||||
* should fall back to the hosted Stripe Checkout flow in that case.
|
||||
*/
|
||||
export function getStripe(): Promise<Stripe | null> | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!key) return null;
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(key);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
/** Synchronous check — useful for deciding whether to render Stripe Elements. */
|
||||
export function hasStripePublishableKey(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user