Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
"leaflet": "^1.9.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-google-button": "^0.8.0",
"react-leaflet": "^5.0.0-rc.2",
"react-router-dom": "^7.0.0",
"xstate": "^5.24.0",
Expand Down
8 changes: 8 additions & 0 deletions src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {useAuthService} from "./useAuthService.ts";
import {useGoogleOidc} from "./google";
import {useSquareOAuth} from "./square.ts";
import Spinner from "../components/common/Spinner";
import useRetailerOnboarding from "../users/retailer/useRetailerOnboarding.ts";

export const AuthProvider = ({children}: { children: ReactNode }) => {

Expand All @@ -27,6 +28,13 @@ export const AuthProvider = ({children}: { children: ReactNode }) => {
square.authenticate();
}, [square]);

// Implicit retailer onboarding: runs once when Square is authorized
const squareToken = auth.pos.square;
const isAuthorized = !!squareToken && new Date(squareToken.expires_at).getTime() > Date.now();
const retailerId = auth.user?.user.role.id ?? null;
const merchantId = squareToken?.merchant_id ?? null;
useRetailerOnboarding({retailerId, merchantId, isAuthorized});

// CORRECT: matches({ authenticated: "idle" })
if (state.matches("loading") || google.isProcessing) {
return (
Expand Down
4 changes: 2 additions & 2 deletions src/auth/authMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ export const authMachine = setup({
const e: any = event ?? {};
const output = (e.output as PosToken | null) ?? null;

console.log("[auth] setPosFromOutput → before:", context.pos);
console.log("[auth] setPosFromOutput → output:", output);
console.debug("[auth] setPosFromOutput → before:", context.pos);
console.debug("[auth] setPosFromOutput → output:", output);

const next = {
square: output, // set Square explicitly
Expand Down
53 changes: 30 additions & 23 deletions src/components/common/GoogleProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,40 @@ const GoogleProfile: React.FC<GoogleProfileProps> = (profile: GoogleProfileProps

return (
<SectionCard cardHeader={{icon: User, title: "Your Google Account"}}>
<div className="flex items-center gap-4 p-4">
{profile.picture ? (
<img
src={profile.picture}
alt={profile.name ?? "User"}
className="w-18 h-18 rounded-full object-cover border"
/>
) : null}
<div className="flex flex-col">
<div className="p-4 space-y-4">
{/* Top row: avatar (left) + logout (right) */}
<div className="flex items-center justify-between gap-4">
{profile.picture ? (
<img
src={profile.picture}
alt={profile.name ?? "User"}
className="w-[72px] h-[72px] rounded-full object-cover border"
/>
) : (
<div className="w-[72px] h-[72px] rounded-full border bg-[color:var(--color-muted)]" aria-hidden="true" />
)}

{/* Logout button (moved up for clearer affordance) */}
<button
type="button"
onClick={handleLogout}
className="ml-auto btn-minimal tap-target flex items-center gap-2 rounded-md px-2 py-2 transition
hover:bg-red-50 hover:text-red-600 active:scale-[0.98]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60
dark:hover:bg-red-900/20 group"
aria-label="Log out"
title="Log out"
>
<LogOut className="w-7 h-7 transition-transform duration-150 group-hover:scale-110"/>
</button>
</div>

{/* Bottom: name and email to better fill space */}
<div className="flex flex-col pt-1">
<span className="text-textPrimary text-lg font-medium">{profile.name ?? "Unknown User"}</span>
<span className="text-textSecondary text-sm">{profile.email ?? "No email on file"}</span>
</div>
</div>

{/* Logout button */}
<button
type="button"
onClick={handleLogout}
className="ml-auto btn-minimal tap-target flex items-center gap-2 rounded-md px-2 py-2 transition
hover:bg-red-50 hover:text-red-600 active:scale-[0.98]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60
dark:hover:bg-red-900/20 group"
aria-label="Log out"
title="Log out"
>
<LogOut className="w-5 h-5 transition-transform duration-150 group-hover:scale-110"/>
</button>
</SectionCard>
)
}
Expand Down
121 changes: 121 additions & 0 deletions src/components/common/GoogleSignIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React from "react";

type Props = {
onClick?: () => void;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
theme?: "light" | "dark" | "neutral";
shape?: "rounded" | "square";
/**
* Visual size variant. "md" follows Google's recommended 40px height.
*/
size?: "sm" | "md" | "lg";
};
/**
* Branded Google Sign-in button following Google's Identity guidelines.
* Uses Google's official downloadable button assets placed under `src/public/google`.
* We intentionally use PNG variants with multiple densities (1x–4x) so browsers can
* pick the best match. This avoids issues with dynamic SVG URL resolution in Vite
* during production builds.
*/
const GoogleSignIn: React.FC<Props> = ({
onClick,
disabled,
fullWidth,
className = "",
size = "lg",
theme = "dark",
shape = "rounded",
}) => {
const sizeClasses =
size === "sm"
? "h-9 text-sm px-3"
: size === "lg"
? "h-12 text-base px-5"
: "h-10 text-sm px-4"; // md

const widthClass = fullWidth ? "w-full" : "w-auto";

// Build a src/srcSet for PNG assets using Vite's import.meta.glob so the
// files are included in the production build (dynamic string URLs won't be processed).
// Files live at: src/public/google/png@{1x..4x}/{theme}/web_{theme}_{rd|sq}_{VARIANT}@{1x..4x}.png
// We'll default to the "SI" (Sign in) variant, then fall back to "ctn" and "na" if needed.
const themeKey = theme; // "neutral" | "light" | "dark"
const shapeKey = shape === "square" ? "sq" : "rd";
const preferredVariants = ["SI", "ctn", "na"]; // order of preference

// Eagerly import all PNGs as URLs
const pngManifest = import.meta.glob(
"../../public/google/png@*/*/*.png",
{ eager: true, as: "url" }
) as Record<string, string>;

// Helper to find the best matching URL among available densities for a given variant
const findDensityUrls = (variant: string) => {
const mk = (d: 1 | 2 | 3 | 4) =>
`../../public/google/png@${d}x/${themeKey}/web_${themeKey}_${shapeKey}_${variant}@${d}x.png`;
const d1 = pngManifest[mk(1)];
const d2 = pngManifest[mk(2)];
const d3 = pngManifest[mk(3)];
const d4 = pngManifest[mk(4)];
return { d1, d2, d3, d4 } as const;
};

let src1x: string | undefined;
let srcSet: string | undefined;
for (const v of preferredVariants) {
const { d1, d2, d3, d4 } = findDensityUrls(v);
if (d1 || d2 || d3 || d4) {
// Choose a reasonable default src and construct srcSet with whatever densities exist
src1x = d1 ?? d2 ?? d3 ?? d4; // browser will still use srcSet if DPR > 1
const parts: string[] = [];
if (d1) parts.push(`${d1} 1x`);
if (d2) parts.push(`${d2} 2x`);
if (d3) parts.push(`${d3} 3x`);
if (d4) parts.push(`${d4} 4x`);
srcSet = parts.join(", ");
break;
}
}

// As an extreme fallback (shouldn't happen), keep empty which will render nothing visible

return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label="Sign in with Google"
className={[
// Keep an accessible, clickable button wrapper with focus ring.
"inline-flex items-center justify-center rounded-[4px]",
// Provide a minimalist transparent background so we don't conflict with baked-in asset styling
"bg-transparent",
// Cursor/disabled handling
"disabled:opacity-60 disabled:cursor-not-allowed",
// Focus style matching Google blue
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#1A73E8]",
sizeClasses,
widthClass,
className,
].join(" ")}
>
{/* The official button asset already contains the Google mark and text. */}
<img
src={src1x}
srcSet={srcSet}
alt="Sign in with Google"
className={[
// Make image height follow the chosen size; width scales automatically.
size === "lg" ? "h-12" : size === "sm" ? "h-9" : "h-10",
// Prevent the image from shrinking in flex layouts.
"block w-auto select-none",
].join(" ")}
draggable={false}
/>
</button>
);
};

export default GoogleSignIn;
14 changes: 6 additions & 8 deletions src/pages/Profile.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import PageHeader from "../components/common/PageHeader.tsx";
import GoogleButton from "react-google-button";
import GoogleSignIn from "../components/common/GoogleSignIn.tsx";
import {useAuth} from "../auth/authContext.ts";
import GoogleProfile from "../components/common/GoogleProfile.tsx";
import SquareAuth from "../users/retailer/SquareAuth.tsx";
Expand Down Expand Up @@ -29,13 +29,11 @@ export const ProfilePage = () => {

<div className="mt-10">
{!isAuthenticated ? (
<div className="flex justify-center">
<GoogleButton
type="light"
label="Sign in with Google"
onClick={startAuthentication}
/>
</div>
<GoogleSignIn
onClick={startAuthentication}
className="w-full sm:w-auto"
size="lg" //default is lg
/>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left: Google Profile */}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/public/google/png@1x/dark/web_dark_sq_na@1x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 38 additions & 1 deletion src/services/retailerGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const RETAILER_QUERY = gql(`
pos
inventory {
name
producer
varietal
vintage
}
Expand Down Expand Up @@ -53,4 +54,40 @@ const RETAILERS_QUERY = gql(`
}
`)

export {RETAILER_QUERY, RETAILERS_QUERY};
const RETAILER_ONBOARDING_MUTATION = gql(`
mutation OnboardRetailer($merchantId: ID!) {
Retailer {
onboard(merchantId: $merchantId, pos: SQUARE) {
id
pos
name
location {
id
website
contactEmail
phone
address
city
state
zipCode
}
}
}
}
`)

// Triggers a backend sync of inventory for the given Square merchant
// Note: despite the name, this is a mutation (kept for backward compatibility)
const RETAILER_INVENTORY_MUTATION = gql(`
mutation SyncRetailerInventory($merchantId: ID!) {
Retailer {
syncInventory(merchantId: $merchantId) {
name
vintage
varietal
}
}
}
`)

export {RETAILER_QUERY, RETAILERS_QUERY, RETAILER_INVENTORY_MUTATION, RETAILER_ONBOARDING_MUTATION};
Loading