diff --git a/index.html b/index.html index e144b18..db8aaac 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,8 @@ - + + { return ( - + = ({ title, desc }) => {

) : null} -
+
); }; diff --git a/src/graphql/domain/schema.graphql b/src/graphql/domain/schema.graphql new file mode 100644 index 0000000..6727d00 --- /dev/null +++ b/src/graphql/domain/schema.graphql @@ -0,0 +1,66 @@ +type Area { + description: String + id: ID + name: String + "Get producers for the given area" + producers: [Producer] +} + +type Country { + description: String + flag: String + id: ID + name: String + "Get regions for the given country" + regions: [Region] + weblink: String +} + +type DomainQuery { + "Get an Area by its ID" + area(areaId: ID!): Area + "Countries that have wine producing regions so far..." + countries: [Country] + "Get a country by its ID" + country(id: ID!): Country + grapes: [Grape] + "Get a region by its ID" + region(id: ID!): Region +} + +type Grape { + color: String + description: String + id: String + name: String +} + +type Producer { + id: String + name: String + slug: String + "Get wines for the given producer" + wines: [Wine] +} + +"Query root" +type Query { + Domain: DomainQuery +} + +type Region { + "Get areas for the given region" + areas: [Area] + description: String + id: ID + name: String + weblink: String +} + +type Wine { + id: String + name: String + slug: String + varietal: String + vintage: String +} diff --git a/src/graphql/producer/schema.graphql b/src/graphql/producer/schema.graphql new file mode 100644 index 0000000..b354062 --- /dev/null +++ b/src/graphql/producer/schema.graphql @@ -0,0 +1,189 @@ +type Fermentation { + days: Int + temperature: Float +} + +type Maceration { + days: Int + temperature: Float +} + +"Mutation root" +type Mutation { + Producer: ProducerMutation + Wine: WineMutation +} + +type Producer { + description: String + email: String + id: ID + name: String + phone: String + slug: String + website: String + "Get wines for the given producer" + wines: [Wine] +} + +type ProducerMutation { + addProducer(producer: ProducerInput!): Producer +} + +type ProducerQuery { + "Get all producers" + all: [Producer] + "Get a producer by its ID" + producer(id: ID!): Producer + "Get producers for a given area" + producers(areaId: ID!): [Producer] +} + +"Query root" +type Query { + Producer: ProducerQuery + Wine: WineQuery +} + +type Wine { + acid: Float + alcohol: Float + bottleAging: Int + closure: String + color: String + description: String + id: ID + name: String + pH: Float + producer: String + shape: String + size: Float + slug: String + subarea: String + type: String + "Can be a single varietal (grape) or blend" + varietal: String + vintage: Int + weblink: String + "Get a wines components" + wineComponents: [WineGrape] +} + +type WineGrape { + fermentation: Fermentation + grapeId: ID + grapeName: String + harvestBegin: String + harvestEnd: String + maceration: Maceration + percentage: Float +} + +type WineMutation { + "Add a new wine to a producer" + addWine(input: WineInput!): Wine + "Add grape and barrel components to a wine" + addWineComponents(input: WineComponentInput!): [WineGrape] +} + +type WineQuery { + "Get a wine by its ID" + wine(id: ID!): Wine +} + +enum Closure { + NATURAL_CORK + OTHER + SCREW_CAP + SYNTHETIC_CORK + TECHNICAL_CORK + VALVE + VINO_SEAL + ZORK +} + +enum Color { + ORANGE + RED + ROSE + WHITE +} + +enum Shape { + ALSACE + BORDEAUX + BOX + BURGUNDY + CALIFORNIA + CHAMPAGNE + OTHER + RHONE +} + +enum Type { + FRIZZANTE + SPARKLING + STILL +} + +input FermentationInput { + days: Int + temperature: Float +} + +input MacerationInput { + days: Int + temperature: Float +} + +input ProducerInput { + areaId: ID! + description: String + email: String + fax: String + name: String! + phone: String + website: String +} + +input WineComponentInput { + fermentation: FermentationInput + grape: String + harvestBegin: Date + harvestEnd: Date + maceration: MacerationInput + percentage: Float +} + +input WineInput { + "Total acidity level (e.g. '5.6')" + acid: Float + "Alcohol content as a percent string (e.g. '13.5')" + alcohol: Float + "Bottle aging duration in months (e.g. '12')" + bottleAging: Int + "Closure type (e.g. CORK, SCREWCAP)" + closure: Closure + "Color of the wine (e.g. RED, WHITE, ROSE)" + color: Color + "Optional general description of the wine" + description: String + "Wine name or label (e.g. 'Château Margaux')" + name: String! + "pH value of the wine (e.g. '3.4')" + pH: Float + "ID of the producer this wine will belong to" + producerId: ID! + "Shape of the bottle (e.g. BORDEAUX, BURGUNDY)" + shape: Shape + "Bottle size in milliliters (e.g. '0.75 or 1.0')" + size: Float + "Optional subarea or vineyard designation (e.g. 'Howell Mountain')" + subarea: String + "Wine type (e.g. STILL, SPARKLING)" + type: Type + "Can be a single varietal (grape) or a blend" + varietal: String! + "Vintage year of the wine (e.g. '2020')" + vintage: Int! +} diff --git a/src/graphql/retailer/schema.graphql b/src/graphql/retailer/schema.graphql new file mode 100644 index 0000000..e54930e --- /dev/null +++ b/src/graphql/retailer/schema.graphql @@ -0,0 +1,88 @@ +type Coordinates { + latitude: Float + longitude: Float +} + +"Mutation root" +type Mutation { + Retailer: RetailerMutation +} + +"Query root" +type Query { + Retailer: RetailerQuery +} + +type Retailer { + "Merchant ID from POS" + id: ID + "Get current (persisted) inventory" + inventory: [RetailerInventory] + location: RetailerLocation + logoUrl: String + name: String + "Point-of-sale system" + pos: String +} + +type RetailerInventory { + description: String + id: ID + name: String + "For POS linking" + originId: String + price: String + producer: String + "External source system" + source: WineSource + varietal: String + vintage: Int + "Get canonical wine matches for the given retailers inventory" + wineMatches: [RetailerInventoryMatch] +} + +type RetailerInventoryMatch { + name: String + producer: String + varietal: String + vintage: Int + wineId: String +} + +type RetailerLocation { + address: String + city: String + contactEmail: String + coordinates: Coordinates + id: ID + phone: String + state: String + website: String + zipCode: String +} + +type RetailerMutation { + "Onboard a Retailer from their POS" + onboard( + "Merchant ID issued by POS" + merchantId: ID!, + pos: WineSource! + ): Retailer + "Sync inventory from Retailer's POS." + syncInventory( + "Merchant ID issued by POS" + merchantId: ID! + ): [RetailerInventory] +} + +type RetailerQuery { + retailer(retailerId: ID!): Retailer + retailers: [Retailer] +} + +enum WineSource { + CANONICAL + CLOVER + SHOPIFY + SQUARE +} diff --git a/src/index.css b/src/index.css index dc255bd..b9ab38b 100644 --- a/src/index.css +++ b/src/index.css @@ -2,25 +2,25 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"); @import "tailwindcss"; -/* Light Mode Design Tokens (no dark mode) */ +/* Monochrome Design Tokens */ :root { - /* brand */ - --color-primary: #7b2c3b; /* deep wine red */ - --color-accent: #e6b17e; /* golden beige */ + /* semantic (monochrome only) */ + --color-primary: #111111; /* use black for primary emphasis in monochrome */ + --color-accent: #6b7280; /* neutral gray used only for focus/selection */ /* surface */ - --color-bg: #fbf8f3; /* clean cream background */ - --color-panel: #ffffff; /* cards/panels */ - --color-muted: #f4efe8; /* subtle background accents */ - --color-border: #1f2937; /* strong borders (near-gray-800) */ + --color-bg: #ffffff; /* pure white background */ + --color-panel: #ffffff; /* panels match background unless elevated by border */ + --color-muted: #f5f5f5; /* subtle gray surface */ + --color-border: #d1d5db; /* neutral-300 border */ /* text */ --color-fg: #111111; /* high-contrast text */ - --color-fg-muted: #5b6070; /* secondary text */ + --color-fg-muted: #6b7280; /* neutral-muted text */ - /* states */ - --color-success: #16a34a; - --color-danger: #dc2626; + /* states (kept neutral; do not use for accents) */ + --color-success: #111111; + --color-danger: #111111; /* typography */ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, @@ -75,7 +75,7 @@ -moz-osx-font-smoothing: grayscale; } ::selection { - background: color-mix(in oklab, var(--color-accent) 35%, transparent); + background: color-mix(in oklab, var(--color-accent) 25%, transparent); color: var(--color-fg); } a { color: inherit; } @@ -132,7 +132,7 @@ .border-token { border-color: var(--color-border); } .text-token { color: var(--color-fg); } - /* Focus ring color uses accent by default */ + /* Focus ring uses neutral gray in monochrome */ .focus-accent { @apply focus-visible:ring-[color:var(--color-accent)] focus-visible:ring-offset-[color:var(--color-bg)]; } } @@ -144,16 +144,11 @@ @layer components { /* SINGLE PRIMARY BUTTON — used for ALL important actions */ .btn-primary { - @apply inline-flex items-center justify-center gap-3 - px-7 py-3.5 - bg-[color:var(--color-primary)] - border-2 border-[color:var(--color-primary)] - text-white + @apply inline-flex items-center justify-center gap-3 px-7 py-3.5 + border-2 border-[color:var(--color-fg)] + bg-[color:var(--color-fg)] text-[color:var(--color-bg)] font-semibold uppercase tracking-wider text-xs - transition-all duration-150 - hover:bg-[#5c0f1e] hover:border-[#5c0f1e] - active:scale-98 - min-h-11 min-w-11; + transition-colors duration-150 active:scale-98 min-h-11 min-w-11; } /* Icon sizing — every primary button MUST have a Lucide icon on the left */ diff --git a/src/layout/Layout.tsx b/src/layout/Layout.tsx index e2dcd30..925bd75 100644 --- a/src/layout/Layout.tsx +++ b/src/layout/Layout.tsx @@ -3,6 +3,7 @@ import {useEffect, useMemo, useState} from "react"; import {useAuth} from "../auth/authContext"; import {type NavLinkDef, resolveNavLinksByRole, toPath} from "../nav/roleNavConfig"; import {Menu, X, LogIn, Search} from "lucide-react"; +import logoUrl from "../public/winegraph.png"; const Layout = () => { const {role, isAuthenticated, user, pos} = useAuth(); @@ -47,101 +48,190 @@ const Layout = () => { }, [mobileOpen]); return ( -
- {/* Sticky Top Nav (logo appears only once here) */} +
+ {/* Skip link */} + Skip + to main content + + {/* Slim header: left-side expanding search, logo on the right */}
-
-
+
+
+ {/* Desktop (sm+): icon-only until hover/focus, then expands */} +
e.preventDefault()} > - {mobileOpen ? ( - + +
+ {/* Right: profile/sign-in (mobile) + logo */} +
+ {/* Mobile/sm-only: show profile/avatar since left rail is hidden */} + + {isAuthenticated ? ( + user?.user?.picture ? ( + + ) : ( + + {(user?.user?.name ?? "").slice(0,1).toUpperCase() || "U"} + + ) ) : ( - +
- {/* Desktop search */} -
- -
- + Wine Graph - -
-
+ +
+ +
+
-
+ + {/* Mobile nav drawer */} + {mobileOpen && ( +
+
setMobileOpen(false)}/> +
-
- )} - - - {/* Main content */} -
-
- + +
-
- - {/* Footer: centered simple tagline (no nav, no brand text) */} - {/*
*/} - {/*
*/} - {/* Welcome to Wine Graph | Discover boldly. Drink wisely.*/} - {/*
*/} - {/*
*/} + )}
); }; export default Layout; + +// --- Local mobile search component (keeps changes contained) --- +import {useCallback} from "react"; + +const MobileHeaderSearch = () => { + const [open, setOpen] = useState(false); + + const onSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + setOpen(false); + }, []); + + return ( +
+ + {open && ( +
+
+ +
+
+ + +
+
+ )} +
+ ); +}; diff --git a/src/main.tsx b/src/main.tsx index e9b0b8c..15f233b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,7 +5,6 @@ import "leaflet/dist/leaflet.css"; import App from "./App.tsx"; import {createBrowserRouter, createRoutesFromElements, Outlet, Route, RouterProvider,} from "react-router-dom"; import {AuthProvider} from "./auth/AuthProvider"; -import RoleBasedHome from "./routes/RoleBasedHome"; import RoleRoute from "./routes/RoleRoute"; import Spinner from "./components/common/Spinner"; @@ -15,6 +14,9 @@ const MarketplacePage = lazy(() => import("./pages/Marketplace.tsx").then(m => ( const ProducerMarketplace = lazy(() => import("./users/producer/ProducerMarketplace.tsx").then(m => ({ default: m.ProducerMarketplace }))); const RetailerInventory = lazy(() => import("./users/retailer/RetailerInventory.tsx").then(m => ({ default: m.RetailerInventory }))); const RetailerProfile = lazy(() => import("./users/retailer/RetailerProfile.tsx").then(m => ({ default: m.RetailerProfile }))); +const GraphFeed = lazy(() => import("./pages/./GraphFeed.tsx")); +const ProducerPage = lazy(() => import("./pages/ProducerPage.tsx")); +const WinePage = lazy(() => import("./pages/WinePage.tsx")); const router = createBrowserRouter( createRoutesFromElements( @@ -23,13 +25,18 @@ const router = createBrowserRouter( }> - }/> + {/* Default main page for all users: Feed (coming soon) */} + )} /> + {/* Dashboard route for signed-in users */} )} /> {/* todo fix routes and navigate throughout */} )} /> {/* RetailerInventory should be 'nested' under Marketplace properly */} )} /> )} /> + {/* Public graph detail routes: human-readable slugs in path; IDs used for data queries */} + )} /> + )} /> {/* --- Retailer section --- */} {/* Retailer-specific routes (require :retailerId) */} diff --git a/src/nav/roleNavConfig.ts b/src/nav/roleNavConfig.ts index 6fe0c07..950b52b 100644 --- a/src/nav/roleNavConfig.ts +++ b/src/nav/roleNavConfig.ts @@ -1,4 +1,13 @@ -import {Home, Globe, Store, User, Package} from "lucide-react"; +import { + Activity, + BarChart3, + Globe, + MessageCircleQuestion, + Package, + Settings, + Store, + User +} from "lucide-react"; import type {ElementType} from "react"; export type NavLinkDef = { @@ -7,12 +16,20 @@ export type NavLinkDef = { route?: string; }; +const genericLinks: NavLinkDef[] = [ + {title: "Activity", icon: Activity, route: "/activity"}, + {title: "Analytics", icon: BarChart3, route: "/analytics"}, + {title: "Settings", icon: Settings, route: "/"}, +]; + // Base links common to most roles const baseLinks: NavLinkDef[] = [ - {title: "Home", icon: Home, route: "/"}, + {title: "Home", icon: BarChart3, route: "/"}, {title: "Discover", icon: Globe, route: "/explore"}, {title: "Marketplace", icon: Store, route: "/marketplace"}, {title: "Profile", icon: User, route: "/profile"}, + {title: "Help", icon: MessageCircleQuestion, route: "/"}, + ...genericLinks, ]; // Role-specific augmentations @@ -20,9 +37,8 @@ function retailerLinks(retailerId: string): NavLinkDef[] { // Use dynamic retailerId paths to match router: /retailer/:retailerId/... const cellar: NavLinkDef = {title: "Cellar", icon: Package, route: `/retailer/${retailerId}/inventory`}; const marketplace: NavLinkDef = {title: "Marketplace", icon: Store, route: "/retailer/marketplace"}; - const profile: NavLinkDef = {title: "Profile", icon: User, route: `/retailer/${retailerId}/profile`}; - // Keep Home as the first item for consistency - return [baseLinks[0], marketplace, cellar, profile]; + //const profile: NavLinkDef = {title: "Profile", icon: User, route: `/retailer/${retailerId}/profile`}; + return [baseLinks[0], marketplace, cellar, baseLinks[4], genericLinks[2]]; } function visitorLinks(): NavLinkDef[] { @@ -34,7 +50,7 @@ function enthusiastLinks(): NavLinkDef[] { } function producerLinks(): NavLinkDef[] { - return baseLinks; + return genericLinks; } export function resolveNavLinksByRole(role: string, userId?: string): NavLinkDef[] { @@ -53,5 +69,6 @@ export function resolveNavLinksByRole(role: string, userId?: string): NavLinkDef // Helper to get concrete path string (resolves buildRoute if present) export function toPath(link: NavLinkDef): string { - return link.route ? link.route : ""; + // Use safe placeholder path if none provided + return link.route ? link.route : "#"; } diff --git a/src/pages/Discover.tsx b/src/pages/Discover.tsx index 9a2901c..3bbf199 100644 --- a/src/pages/Discover.tsx +++ b/src/pages/Discover.tsx @@ -2,11 +2,13 @@ import React, {type ReactNode, useMemo, useState} from "react"; import {DomainCard} from "../components/DomainCard.tsx"; import {useQuery} from "@apollo/client"; import {DOMAIN_QUERY} from "../services/domainGraph.ts"; +import {PRODUCERS_BY_AREA} from "../services/producerGraph.ts"; import PageHeader from "../components/common/PageHeader.tsx"; // CrumbButton removed in favor of text-label breadcrumbs per new design spec -import {domainClient} from "../services/apolloClient.ts"; +import {domainClient, producerClient} from "../services/apolloClient.ts"; import type {Producer} from "../users/producer/producer.ts"; import Spinner from "../components/common/Spinner.tsx"; +import {ProducerCard} from "../users/producer/ProducerCard.tsx"; type Country = { id: string; @@ -46,6 +48,13 @@ const DomainList = () => { const [selectedAreaId, setSelectedAreaId] = useState(null); const [selectedProducerId, setSelectedProducerId] = useState(null); + const {data: producersData, loading: producersLoading} = useQuery(PRODUCERS_BY_AREA, { + client: producerClient, + variables: {areaId: selectedAreaId as string | undefined}, + skip: !selectedAreaId, + }); + const producers = useMemo(() => (producersData?.Producer?.producers as Producer[]) ?? [], [producersData]); + const countries = useMemo(() => (data?.Domain?.countries as Country[]) ?? [], [ data, ]); @@ -66,8 +75,8 @@ const DomainList = () => { ); const selectedProducer = useMemo(() => - selectedArea?.producers?.find((p: Producer) => p.id === selectedProducerId) ?? null, - [selectedArea, selectedProducerId] + producers.find((p: Producer) => p.id === selectedProducerId) ?? null, + [producers, selectedProducerId] ); const showCountries = !selectedCountry; @@ -181,11 +190,25 @@ const DomainList = () => { onClick={() => setSelectedAreaId(a.id)} /> ))} - {showProducers && - selectedArea?.producers?.map((p: Producer) => ( - setSelectedProducerId(p.id)} - /> - ))} + {showProducers && ( + producersLoading ? ( +
+ +
+ ) : ( + producers.map((p: Producer) => ( + + )) + ) + )} )} diff --git a/src/pages/GraphFeed.tsx b/src/pages/GraphFeed.tsx new file mode 100644 index 0000000..a3c2c06 --- /dev/null +++ b/src/pages/GraphFeed.tsx @@ -0,0 +1,101 @@ +import {Link} from "react-router-dom"; +import {useAuth} from "../auth/authContext"; + +const GraphFeed = () => { + const {isRetailer, isProducer} = useAuth(); + + // Role-aware content (simple placeholders; data hooks will replace later) + const youContent = (() => { + if (isRetailer) { + return { + contribution: ["421 items synced from your POS"], + gets: ["27 wines matched into the graph", "Shown in search near your locations"], + actionLabel: "View inventory", + actionHref: "/marketplace", + }; + } + if (isProducer) { + return { + contribution: ["18 wines with up-to-date data"], + gets: ["Listed by 4 retailers", "Visible in 3 regions"], + actionLabel: "View your wines", + actionHref: "/explore", + }; + } + return { + contribution: ["No wines viewed yet"], + gets: ["Start exploring to improve suggestions for everyone"], + actionLabel: "Browse wines", + actionHref: "/explore", + }; + })(); + + return ( +
+
+ {/* Search + tagline */} +
+
+ +
+
+

Everyone contributes. Everyone wins.

+

Retailers sync inventory, producers refine data, and everyone gets + a better map of wine.

+
+
+ + {/* First row: snapshot + You in the graph */} +
+ {/* You in the graph card */} +
+

You in the graph

+
+
+
Your contribution
+
    + {youContent.contribution.map((line, i) => ( +
  • {line}
  • + ))} +
+
+
+
What you get
+
    + {youContent.gets.map((line, i) => ( +
  • {line}
  • + ))} +
+
+
+
+ + {youContent.actionLabel} + +
+
+ + {/* Global snapshot placeholder */} +
+

Global snapshot

+

Future metrics and activity across the Wine Graph will appear here.

+
+
+ + {/* Lower future feed region */} +
+
+

Wine Graph feed (coming soon)

+

Recent events and activity from across the graph will appear here.

+
+
+
+
+ ); +}; + +export default GraphFeed; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx deleted file mode 100644 index d0fa510..0000000 --- a/src/pages/Home.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; -import Hero from "../components/common/Hero.tsx"; - -/** - * Home page component for all users types - * @param type - */ -export const HomePage: React.FC<{ userType: string }> = (type) => { - switch ((type.userType || "visitor").toLowerCase()) { - case "retailer": - return ; - case "producer": - return ; - case "enthusiast": - return ; - case "visitor": - default: - return ; - } -}; - -const VisitorHomePage = () => { - return ( - - ); -}; - -const RetailerHomePage = () => { - return ( - - ); -}; - -const ProducerHomePage = () => { - return ( - - ); -}; - -const EnthusiastHomePage = () => { - return ( - - ); -}; diff --git a/src/pages/ProducerPage.tsx b/src/pages/ProducerPage.tsx new file mode 100644 index 0000000..28b9bb9 --- /dev/null +++ b/src/pages/ProducerPage.tsx @@ -0,0 +1,219 @@ +import {Link, useParams} from "react-router-dom"; +import {useMemo} from "react"; +import {useQuery} from "@apollo/client"; +import {producerClient} from "../services/apolloClient"; +import {PRODUCER_BY_ID} from "../services/producerGraph"; +import type {Wine} from "../types/wine.ts"; + +type Producer = { + id: string; + name: string; + slug: string; + website?: string; + grapes?: string[]; + styles?: string[]; + founded?: string; + description?: string; + wines: Wine[]; + retailers: { name: string; location?: string }[]; + metrics?: { wines?: number; retailers?: number; regions?: number; updatedAt?: string }; +}; + +function SkeletonRow() { + return ( +
+ ); +} + +export default function ProducerPage() { + const {id} = useParams(); + const {data, loading, error, refetch} = useQuery(PRODUCER_BY_ID, { + client: producerClient, + variables: {id}, + skip: !id, + }); + + const producer = data?.Producer?.producer as Producer | undefined; + + const headerSubtitle = useMemo(() => { + if (!producer) return ""; + }, [producer]); + + return ( +
+ {/* Breadcrumb */} + + + {/* Header band */} +
+
+

{producer?.name || (loading ? "Loading…" : producer?.slug)}

+ {headerSubtitle && ( +

{headerSubtitle}

+ )} +
+ {/* Right side role-aware placeholder */} +
+ {/* Placeholder for role-aware controls; keep monochrome */} +
+
+ + {/* Page states */} + {loading && ( +
+
+ + + +
+
+ )} + {error && ( +
+
+

We couldn’t load this producer. Retry in a moment.

+
+ +
+
+
+ )} + + {(!loading && !error && producer) && ( +
+ {/* Overview + In the graph */} +
+ {/* Overview card */} +
+

Overview

+
+ {(producer.description && producer.description.length > 0) && ( +
+
Description
+
{producer.description}
+
+ )} + {(producer.grapes && producer.grapes.length > 0) && ( +
+
Grapes
+
{producer.grapes.join(", ")}
+
+ )} + {(producer.styles && producer.styles.length > 0) && ( +
+
Styles
+
{producer.styles.join(", ")}
+
+ )} + {producer.founded && ( +
+ Founded + {producer.founded} +
+ )} + {producer.website && ( +
+ Website + Visit +
+ )} +
+
+ + {/* In the graph card */} +
+

In the graph

+
    +
  • {producer.wines.length ?? "—"} wines in Wine Graph
  • +
  • {producer.metrics?.retailers ?? "0"} retailers carrying this producer
  • +
  • {producer.metrics?.regions ?? "0"} areas (AVA, AOC, etc)
  • +
  • Last + updated: {producer.metrics?.updatedAt ? new Date(producer.metrics.updatedAt).toLocaleString() : "—"}
  • +
+
+
+ + {/* Wines + Retailers row */} +
+ {/* Wines list/table */} +
+

Wines from this producer

+
+ {(!producer.wines || producer.wines.length === 0) ? ( +

No wines for this producer in Wine Graph yet.

+ ) : ( +
+ + + + + + + + + + {producer.wines.map((w) => ( + + + + + + ))} + +
WineVintageColor / Style
+ {(w.slug && w.id) ? ( + {w.name} + ) : ( + {w.name} + )} + {w.vintage ?? "—"}{[w.color, w.varietal].filter(Boolean).join(" / ") || "—"}
+
+ )} +
+
+ + {/* Retailers */} +
+

Retailers

+
+ {producer.retailers?.length === 0 ? ( +

No retailers listed yet.

+ ) : ( + producer.retailers?.map((r, idx) => ( +
+ {r.name} + {r.location || ""} +
+ )) + )} +
+
+
+ + {/* Data section */} +
+

Data

+

Data completeness, corrections, and history will appear here.

+
+
+ )} +
+ ); +} diff --git a/src/pages/WinePage.tsx b/src/pages/WinePage.tsx new file mode 100644 index 0000000..f662de6 --- /dev/null +++ b/src/pages/WinePage.tsx @@ -0,0 +1,239 @@ +import {Link, useParams} from "react-router-dom"; +import {useMemo, useRef} from "react"; +import {useQuery} from "@apollo/client"; +import {WINE_BY_ID} from "../services/producerGraph.ts"; +import {producerClient} from "../services/apolloClient.ts"; + +type Wine = { + name: string; + slug: string; + vintage?: string | number; + varietal: string; + producer?: { id: string; name: string; slug: string }; + area?: string; + region?: string; + country?: string; + grapes?: string[]; + color?: string; // red, white, sparkling, etc. + type?: string; // alias for color/type coming from backend variants + size?: string; // 750ml + abv?: string | number; + description?: string; + retailers: { name: string; location?: string; price?: string }[]; + similarWines: { id?: string; name: string; slug: string; vintage?: string | number; producer?: { name: string } }[]; + metrics?: { retailers?: number; regions?: number; matches?: number; updatedAt?: string; since?: string }; +}; + +function SkeletonRow() { + return
; +} + +export default function WinePage() { + const {id} = useParams(); + const retailersRef = useRef(null); + + const {data, loading, error, refetch} = useQuery(WINE_BY_ID, {client: producerClient, variables: {id}, skip: !id}); + const wine = data?.Wine?.wine as Wine | undefined; + + const subtitle = useMemo(() => { + if (!wine) return ""; + const parts: string[] = []; + if (wine.vintage) parts.push(String(wine.vintage)); + if (wine.producer) parts.push(wine.producer.name); + const place = [wine.area, wine.region, wine.country].filter(Boolean).join(", "); + if (place) parts.push(place); + return parts.join(" • "); + }, [wine]); + + return ( +
+ {/* Breadcrumb */} + + + {/* Header band */} +
+
+

{wine?.name || (loading ? "Loading…" : wine?.slug)}

+ {subtitle && ( +

+ {wine?.producer?.name ? ( + <> + + {wine.producer.name} + + ) : null} + {(() => { + const place = [wine?.area, wine?.region, wine?.country].filter(Boolean).join(", "); + return place ? (<>{place}) : null; + })()} +

+ )} +
+
+ {wine?.metrics?.since && ( + In Wine Graph since {wine.metrics.since} + )} +
+
+ + {/* States */} + {loading && ( +
+
+ + + +
+
+ )} + {error && ( +
+
+

We couldn’t load this wine. Retry in a moment.

+
+ +
+
+
+ )} + + {(!loading && !error && wine) && ( +
+ {/* Profile + In the graph */} +
+ {/* Profile */} +
+

Profile

+
+ {wine.producer && ( +
+ Producer + {wine.producer.name} +
+ )} + {wine.vintage && ( +
Vintage{wine.vintage} +
+ )} + {(wine.varietal) && ( +
Varietal{wine.varietal}
+ )} + {wine.grapes && wine.grapes.length > 0 && ( +
+
Grapes
+
{wine.grapes.join(", ")}
+
+ )} + {wine.size && ( +
Size{wine.size}
+ )} + {wine.abv && ( +
ABV{wine.abv}
+ )} + {wine.description && ( +
+
Notes
+

{wine.description}

+ )} +
+
+ + {/* In the graph */} +
+

In the graph

+
    + {/*
  • {wine.metrics?.retailers ?? "—"} retailers carrying this wine
  • */} +
  • {wine.metrics?.regions ?? "—"} regions
  • +
  • {wine.metrics?.matches ?? "—"} matches / references
  • +
  • Last + updated: {wine.metrics?.updatedAt ? new Date(wine.metrics.updatedAt).toLocaleString() : "—"}
  • +
+ {/* Role-aware examples (text only, no branching layout) */} +

{/* Retailer/Producer context could show here in the future. */}

+
+
+ + {/* Retailers + Similar wines */} +
+ {/* Retailers carrying this wine */} +
+

Retailers carrying this + wine

+
+ {wine.retailers?.length === 0 ? ( +

No retailers listed yet.

+ ) : ( +
+ + + + + + + + + {wine.retailers?.map((r, i) => ( + + + + + ))} + +
RetailerLocation
{r.name}{r.location || "—"}
+
+ )} +
+
+ + {/* Similar wines */} +
+

Similar wines

+
+ {wine.similarWines?.length === 0 ? ( +

Similar wines will appear here as the graph grows.

+ ) : ( + wine.similarWines?.map((sw) => ( +
+ {sw.id ? ( + + {sw.name}{sw.vintage ? ` ${sw.vintage}` : ""} + + ) : ( + {sw.name}{sw.vintage ? ` ${sw.vintage}` : ""} + )} + {sw.producer?.name ? — {sw.producer.name} : null} +
+ )) + )} +
+
+
+
+ )} +
+ ); +} diff --git a/src/public/winegraph.png b/src/public/winegraph.png new file mode 100644 index 0000000..fde0fff Binary files /dev/null and b/src/public/winegraph.png differ diff --git a/src/routes/RoleBasedHome.tsx b/src/routes/RoleBasedHome.tsx deleted file mode 100644 index 19c98d6..0000000 --- a/src/routes/RoleBasedHome.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {HomePage} from "../pages/Home.tsx"; -import {useAuth} from "../auth/authContext.ts"; - -const RoleBasedHome = () => { - const {role} = useAuth(); - - // Extend this mapping as new roles are added - if (role === "retailer") { - return ; - } - - // Default visitor landing page - return ; -}; - -export default RoleBasedHome; diff --git a/src/services/domainGraph.ts b/src/services/domainGraph.ts index 5803c5d..29fdf8e 100644 --- a/src/services/domainGraph.ts +++ b/src/services/domainGraph.ts @@ -15,10 +15,6 @@ const DOMAIN_QUERY = gql(` id name description - producers { - id - name - } } } } diff --git a/src/services/producerGraph.ts b/src/services/producerGraph.ts index e26f549..18c83f1 100644 --- a/src/services/producerGraph.ts +++ b/src/services/producerGraph.ts @@ -5,6 +5,7 @@ const PRODUCERS_QUERY = gql(` Producer { producers { id + slug name description email @@ -13,10 +14,71 @@ const PRODUCERS_QUERY = gql(` wines { id name + slug } } } } `) -export {PRODUCERS_QUERY}; \ No newline at end of file +const PRODUCER_BY_ID = gql(` + query($id: ID!) { + Producer { + producer(id: $id) { + id + slug + name + description + email + phone + wines { + id + name + slug + vintage + color + varietal + } + } + } + } +`) + +const PRODUCERS_BY_AREA = gql(` + query($areaId: ID!) { + Producer { + producers(areaId: $areaId) { + id + slug + name + description + email + phone + website + } + } + } +`) + +const WINE_BY_ID = gql(` + query($id: ID!) { + Wine { + wine(id: $id) { + id + name + slug + description + varietal + vintage + alcohol + producer + color + closure + type + shape + } + } + } +`) + +export {PRODUCERS_QUERY, PRODUCER_BY_ID, WINE_BY_ID, PRODUCERS_BY_AREA} \ No newline at end of file diff --git a/src/types/wine.ts b/src/types/wine.ts index 4a4a1b3..339d3a5 100644 --- a/src/types/wine.ts +++ b/src/types/wine.ts @@ -1,6 +1,7 @@ export type Wine = { id: string; name: string; + slug: string; vintage: number; varietal: string; size: number; @@ -17,7 +18,6 @@ export type Wine = { subarea?: string; weblink?: string; wineComponents?: WineGrape[]; - pricePerBottle?: number; } type WineGrape = { diff --git a/src/users/producer/ProducerCard.tsx b/src/users/producer/ProducerCard.tsx index 5a97349..72098da 100644 --- a/src/users/producer/ProducerCard.tsx +++ b/src/users/producer/ProducerCard.tsx @@ -1,35 +1,46 @@ import {Building2, ChevronRight, Globe, Mail, Phone, Wine} from "lucide-react"; import type {Producer} from "./producer.ts"; +import {useCallback} from "react"; +import {useNavigate} from "react-router-dom"; -export const ProducerCard: React.FC = ({name, description, wines = [], email, phone, website}) => { +export const ProducerCard: React.FC = ({id, name, slug, description, wines = [], email, phone, website}) => { const wineCount = wines.length; - const featuredWines = wines.slice(0, 3).map(w => w.name).filter(Boolean); - const moreCount = wineCount - featuredWines.length; + //const featuredWines = wines.slice(0, 3).map(w => w.name).filter(Boolean); + //const moreCount = wineCount - featuredWines.length; const hasEmail = !!email?.trim(); const hasPhone = !!phone?.trim(); const hasWebsite = !!website?.trim(); + const navigate = useNavigate(); + const handleOpen = useCallback(() => { + // Human-readable route with slug + ID; query will use ID + if (!id) return; + const safeSlug = (slug && slug.trim()) || (name ? name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") : "producer"); + navigate(`/producer/${safeSlug}/${id}`); + }, [navigate, id, slug, name]); + return (
- {/* Subtle accent ring on hover */} -
+ role="link" + tabIndex={0} + onClick={handleOpen} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpen(); } }} + className="group relative panel-token border border-[color:var(--color-border)] rounded-xl p-6 hover:border-[color:var(--color-border)] hover:bg-[color:var(--color-neutral-100)] hover:shadow-sm hover:-translate-y-0.5 transition-all duration-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-[color:var(--color-fg)]">
{/* Header with icon + name */}
- +

{name}

+ className="w-5 h-5 text-[color:var(--color-fg-muted)] group-hover:text-[color:var(--color-fg)] group-hover:translate-x-1 transition-all"/>
{/* Description */} @@ -41,18 +52,18 @@ export const ProducerCard: React.FC = ({name, description, wines = [], {/* Wine count + preview */}
- - {wineCount} {wineCount === 1 ? "wine" : "wines"} + + {wineCount} {wineCount === 1 ? "wine" : "wines"} - {featuredWines.length > 0 && ( - - {" · "} {featuredWines.join(", ")} - {moreCount > 0 && ` +${moreCount} more`} - - )} + {/*{featuredWines.length > 0 && (*/} + {/* */} + {/* {" · "} {featuredWines.join(", ")}*/} + {/* {moreCount > 0 && ` +${moreCount} more`}*/} + {/* */} + {/*)}*/}
- {/* Icon-only contact bar — exactly like your Retailer tiles */} + {/* Contact links (monochrome) */} {(hasEmail || hasPhone || hasWebsite) && (
{hasWebsite && ( @@ -60,7 +71,7 @@ export const ProducerCard: React.FC = ({name, description, wines = [], href={website?.startsWith("http") ? website : `https://${website}`} target="_blank" rel="noopener noreferrer" - className="text-[color:var(--color-fg-muted)] hover:text-[color:var(--color-primary)] transition-colors" + className="text-[color:var(--color-fg-muted)] hover:text-[color:var(--color-fg)] transition-colors" onClick={(e) => e.stopPropagation()} title="Visit website" > @@ -70,7 +81,7 @@ export const ProducerCard: React.FC = ({name, description, wines = [], {hasEmail && ( e.stopPropagation()} title="Send email" > @@ -80,7 +91,7 @@ export const ProducerCard: React.FC = ({name, description, wines = [], {hasPhone && ( e.stopPropagation()} title="Call" > diff --git a/src/users/producer/ProducerMarketplace.tsx b/src/users/producer/ProducerMarketplace.tsx index 426746c..dff1594 100644 --- a/src/users/producer/ProducerMarketplace.tsx +++ b/src/users/producer/ProducerMarketplace.tsx @@ -7,18 +7,31 @@ import {useMemo} from "react"; import type {Producer} from "./producer.ts"; export const ProducerMarketplace = () => { - const {data, loading} = useQuery(PRODUCERS_QUERY, {client: producerClient}); + const {data, loading, error, refetch} = useQuery(PRODUCERS_QUERY, {client: producerClient}); const producers = useMemo(() => (data?.Producer?.producers as Producer[]) ?? [], [data]); return (
- {loading ? ( + {error ? ( +
+

We couldn’t load producers. Retry in a moment.

+
+ +
+
+ ) : loading ? (
Loading producers…
) : producers.length === 0 ? (
@@ -27,7 +40,7 @@ export const ProducerMarketplace = () => { No producers yet

- We're onboarding exceptional producers daily. Check back soon! + Producers will appear here as they join Wine Graph.

diff --git a/src/users/producer/producer.ts b/src/users/producer/producer.ts index 687efa6..68ba260 100644 --- a/src/users/producer/producer.ts +++ b/src/users/producer/producer.ts @@ -1,6 +1,7 @@ export type Producer = { id: string; name: string; + slug: string; email?: string; phone?: string; website?: string; diff --git a/src/users/retailer/RetailerInventory.tsx b/src/users/retailer/RetailerInventory.tsx index dff2b58..087b241 100644 --- a/src/users/retailer/RetailerInventory.tsx +++ b/src/users/retailer/RetailerInventory.tsx @@ -6,7 +6,7 @@ import RetailerInventoryTable from "./RetailerInventoryTable.tsx"; import {Globe, Mail, MapPin, Phone, Store, RefreshCcw} from "lucide-react"; import Spinner from "../../components/common/Spinner.tsx"; import {useAuth} from "../../auth/authContext.ts"; -import {useMemo, useState} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; export const RetailerInventory = () => { const {user, isRetailer, pos} = useAuth(); @@ -25,6 +25,11 @@ export const RetailerInventory = () => { const retailer = data?.Retailer?.retailer; const inventory = Array.isArray(retailer?.inventory) ? retailer.inventory : []; + // Local UI state for List + Detail pattern + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const lastFocusedRowRef = useRef(null); + const canSync = useMemo(() => { const square = pos.square; const isAuthorized = !!square && new Date(square.expires_at).getTime() > Date.now(); @@ -47,6 +52,33 @@ export const RetailerInventory = () => { } }; + // Keyboard: close drawer with Esc + useEffect(() => { + if (!selected) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + setSelected(null); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selected]); + + // Filtered rows based on search query (name/varietal/vintage) + const filteredInventory = useMemo(() => { + if (!query.trim()) return inventory; + const q = query.toLowerCase(); + return inventory.filter((w: any) => { + const vintage = w.vintage ? String(w.vintage) : ""; + return ( + (w.name?.toLowerCase?.() ?? "").includes(q) || + (w.varietal?.toLowerCase?.() ?? "").includes(q) || + vintage.toLowerCase().includes(q) + ); + }); + }, [inventory, query]); + if (loading) return ; if (!retailer) return
Retailer not found.
; @@ -67,7 +99,7 @@ export const RetailerInventory = () => {

- +
@@ -76,7 +108,7 @@ export const RetailerInventory = () => { {/* Street address: show on md+, hide on smaller screens to reduce clutter */} {retailer.location?.address && (
- + {retailer.location.address} @@ -86,8 +118,8 @@ export const RetailerInventory = () => { {/* Keep phone visible on mobile for quick action */} {retailer.location?.phone && ( - + className="flex items-center gap-2.5 transition"> + {retailer.location.phone} )} @@ -95,8 +127,8 @@ export const RetailerInventory = () => { {/* Email: hide on mobile, show from md+ */} {retailer.location?.contactEmail && ( - + className="hidden md:flex items-center gap-2.5 transition"> + Email )} @@ -104,8 +136,8 @@ export const RetailerInventory = () => { {/* Website: hide on mobile, show from md+ */} {retailer.location?.website && ( - + className="hidden md:flex items-center gap-2.5 transition"> + Website )} @@ -124,7 +156,7 @@ export const RetailerInventory = () => { aria-label="Sync Inventory" > - {syncing ? "Syncing inventory…" : "Sync Inventory from Square"} + {syncing ? "Syncing inventory…" : "Sync inventory from Square"}
{syncing ? "Please wait, this may take up to a minute." : ""} @@ -142,7 +174,7 @@ export const RetailerInventory = () => { > - {syncing ? "Syncing inventory…" : "Sync Inventory"} + {syncing ? "Syncing inventory…" : "Sync inventory"}
@@ -154,35 +186,122 @@ export const RetailerInventory = () => {
- {/* Inventory Table */} + {/* Inventory: List + Detail pattern */}
-
-

{inventory.length} Wines Available

+ {/* Screen header: title + actions */} +
+
+

Inventory

+

{inventory.length} wines available

+
+
+ + {/* Controls row: search above table */} +
+ setQuery(e.target.value)} + placeholder="Search inventory…" + className="w-full h-10 px-3 border-2 border-[color:var(--color-border)] bg-[color:var(--color-panel)] placeholder-[color:var(--color-fg-muted)]" + aria-label="Search inventory" + />
{banner && (
+ {banner.type === "success" ? "Success:" : "Error:"} {banner.message}
)} - {inventory.length === 0 ? ( + {filteredInventory.length === 0 ? (
- No wines in inventory yet. + {inventory.length === 0 ? ( + <> +
No wines in inventory yet.
+ {canSync && ( +
+ +
+ )} + + ) : ( + <> +
No inventory matches your search.
+ + + )}
) : ( - + setSelected(w)} + // capture the focused row to restore focus when closing drawer + onRowFocus={(el) => { lastFocusedRowRef.current = el; }} + /> )}
+ + {/* Detail drawer */} + {selected && ( +
+ {/* scrim */} + +
+
+
+

Summary

+
+
Producer: {selected?.producer ?? "—"}
+
Name: {selected?.name ?? "—"}
+
Vintage: {selected?.vintage ?? "—"}
+
Varietal: {selected?.varietal ?? "—"}
+
+
+
+

Matching info

+
Not matched yet. Matching runs periodically in the background.
+
+
+ +
+
+ +
+ )} + + {/* Restore focus to triggering row when drawer closes */} + {!selected && lastFocusedRowRef.current && ( + + )}
); +}; + +// Utility component to restore focus after drawer close +const FocusRestorer = ({target}: {target: HTMLElement}) => { + useEffect(() => { + target?.focus?.(); + }, [target]); + return null; }; \ No newline at end of file diff --git a/src/users/retailer/RetailerInventoryTable.tsx b/src/users/retailer/RetailerInventoryTable.tsx index a94c895..1fc6970 100644 --- a/src/users/retailer/RetailerInventoryTable.tsx +++ b/src/users/retailer/RetailerInventoryTable.tsx @@ -10,10 +10,12 @@ type Wine = { type Props = { wines: Wine[]; onRowClick?: (wine: Wine) => void; + // Called when a row/card receives focus so caller can restore focus after closing drawer + onRowFocus?: (el: HTMLElement, wine: Wine) => void; }; // A dead-simple, reusable inventory table with responsive stacked rows on mobile -const RetailerInventoryTable: React.FC = ({ wines, onRowClick }) => { +const RetailerInventoryTable: React.FC = ({ wines, onRowClick, onRowFocus }) => { // Desktop table (≥768px) const TableDesktop = ( @@ -34,8 +36,17 @@ const RetailerInventoryTable: React.FC = ({ wines, onRowClick }) => { return ( onRowClick?.(w)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(w); + } + }} + onFocus={(e) => onRowFocus?.(e.currentTarget as HTMLElement, w)} > {w.vintage ?? "—"} {w.producer} @@ -59,8 +70,17 @@ const RetailerInventoryTable: React.FC = ({ wines, onRowClick }) => { return (
  • onRowClick?.(w)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(w); + } + }} + onFocus={(e) => onRowFocus?.(e.currentTarget as HTMLElement, w)} >
    {w.vintage ? `[${w.vintage}] ` : ""}{w.producer} – {w.name} diff --git a/src/users/retailer/SquareAuth.tsx b/src/users/retailer/SquareAuth.tsx index 3e3882b..38355f7 100644 --- a/src/users/retailer/SquareAuth.tsx +++ b/src/users/retailer/SquareAuth.tsx @@ -21,29 +21,29 @@ const SquareAuth = () => { icon: Store, title: "Square POS", }} - className="border-2 hover:border-[color:var(--color-primary)] hover:bg-[color:var(--color-muted)] hover:shadow-md hover:-translate-y-px transition-all duration-300 focus-visible:border-[color:var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[color:var(--color-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--color-bg)]" + className="border-2 border-neutral-200 hover:bg-neutral-50 transition-colors focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 focus-visible:ring-offset-white" >
    {/* Large QrCode on the left — clearly clickable */} -
    +
    {/* All text to the right */}
    -

    - Connect your Square account to sync inventory and share! +

    + Connect your Square account to sync inventory into Wine Graph.

    - + Secure OAuth • Takes ~20 seconds {/* Subtle minimal cue on the far right */} - + Connect - +