778b3fe311
MOBILE RESPONSIVENESS (Apple HIG): - HeroSection: typography scaled 7xl→4xl sm:5xl md:6xl lg:7xl, responsive px/py, rounded-2xl, active:scale-95 - Cart quantity buttons: h-8→h-11 w-8→w-11 (44px touch target), rounded-xl, active:scale-95 - StorefrontFooter newsletter: responsive w-full sm:w-52 lg:w-64, aria-label, larger touch targets - StorefrontHeader mobile nav: padding px-6→px-4 py-6→py-5 - AdminTable: overflow-x-auto + min-w-[600px] for horizontal scroll - AdminOrdersPanel: same table overflow fix - AdminLayout: mobile px-4 sm:px-6 py-6 sm:py-10 - AdminFilterTabs: responsive text/text sizes - AdminSidebar hamburger: h-10→h-11 w-10→w-11 (44px touch target) - DashboardClient: grid gap-3 sm:gap-4, responsive stat text - OrderEditForm: grid-cols-1 sm:grid-cols-2 (was 2, breaks on mobile) - BillingClient: min-h-[44px] on select/button - ProductsClient: h-32 sm:h-40 responsive image height - StopCard: line-clamp-2 instead of line-clamp-1 on location - CommunicationsPage: tabs wrapped in overflow-x-auto - Checkout page: grid breakpoint md not lg - Tuxedo page: SectionHeader mobile-first, feature grid grid-cols-1 sm:2, label visible - Tuxedo stats: text-3xl sm:text-4xl SEO METADATA: - Root layout: viewport export, full OG/Twitter, metadataBase, keywords, robots - Tuxedo layout: complete OG + Twitter + canonical + keywords - Indian River layout: complete OG + Twitter + canonical + keywords - Tuxedo/IRD FAQ pages: new layout.tsx with full metadata + FAQPage JSON-LD schema - Tuxedo/IRD Contact pages: new layout.tsx with full metadata - Pricing page: expanded metadata with OG/Twitter - Contact page: refactored to layout+ClientPage structure - Sitemap: updated with dynamic stop URLs, async function SCHEMA + STRUCTURED DATA: - FAQPage JSON-LD on FAQ pages - BreadcrumbList JSON-LD on storefront layouts - BreadcrumbNav component created (Apple HIG compliant) BUG FIXES: - Indian River: replaced raw <img> with Next.js Image component - Indian River: verified single H1 (others are h2) - Stop card: location line-clamp-2 for better readability TYPE CHECK: all pass
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
"use server";
|
|
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import { svcHeaders } from "@/lib/svc-headers";
|
|
|
|
export type StopImportRow = {
|
|
city: string;
|
|
state: string;
|
|
location: string;
|
|
date: string;
|
|
time: string;
|
|
address?: string;
|
|
zip?: string;
|
|
notes?: string;
|
|
};
|
|
|
|
export async function createStopsBatch(
|
|
brandId: string,
|
|
stops: StopImportRow[]
|
|
): 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 to manage stops" };
|
|
}
|
|
|
|
const effectiveBrandId = brandId || adminUser.brand_id;
|
|
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 rows = stops.map((s) => {
|
|
const slug = `${s.city.toLowerCase().replace(/\s+/g, "-")}-${s.date || new Date().toISOString().slice(0, 10)}`;
|
|
return {
|
|
city: s.city,
|
|
state: s.state,
|
|
location: s.location,
|
|
date: s.date || "",
|
|
time: s.time || "",
|
|
address: s.address || null,
|
|
zip: s.zip || null,
|
|
brand_id: effectiveBrandId,
|
|
slug,
|
|
status: "draft",
|
|
active: false,
|
|
};
|
|
});
|
|
|
|
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
|
method: "POST",
|
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
|
body: JSON.stringify(rows),
|
|
});
|
|
|
|
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();
|
|
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
|
|
}
|
|
|
|
export async function publishStop(
|
|
stopId: 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/stops?id=eq.${stopId}`, {
|
|
method: "PATCH",
|
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "active", active: true }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: "Patch failed" }));
|
|
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Fetch active stops for sitemap generation.
|
|
* This is a public function that doesn't require authentication.
|
|
*/
|
|
export type StopForSitemap = {
|
|
slug: string;
|
|
brand_slug: string;
|
|
last_modified: string;
|
|
};
|
|
|
|
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
|
|
// Get all active stops with their brand slug
|
|
const response = await fetch(
|
|
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
|
{
|
|
method: "POST",
|
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
|
|
if (!response.ok) return [];
|
|
|
|
const stops = await response.json();
|
|
return Array.isArray(stops) ? stops : [];
|
|
} |