Skip to content
Open
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: 13 additions & 0 deletions examples/next/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,19 @@ const Header = () => {
>
Phantom
</Button>
<Button
onClick={() => {
controllerConnector.connect({
locationGate: {
allowedStates: ["CA", "NY"],
},
});
}}
disabled={!isControllerReady}
className="bg-[#7BA6F6] hover:bg-[#6A96E6] text-white"
>
Region Gate
</Button>
{sessionConnector && (
<Button
onClick={() => connect({ connector: sessionConnector })}
Expand Down
80 changes: 79 additions & 1 deletion examples/next/src/components/Profile.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { toast } from "@cartridge/controller";
import { useMemo, useState } from "react";
import { ResponseCodes, toast } from "@cartridge/controller";
import { useAccount } from "@starknet-react/core";
import ControllerConnector from "@cartridge/connector/controller";
import { Button } from "@cartridge/ui";
Expand All @@ -12,6 +13,22 @@ import {
export function Profile() {
const { account, connector } = useAccount();
const ctrlConnector = connector as unknown as ControllerConnector;
const [locationCoords, setLocationCoords] = useState<{
latitude: number;
longitude: number;
} | null>(null);
const mapUrl = useMemo(() => {
if (!locationCoords) {
return null;
}
const { latitude, longitude } = locationCoords;
const delta = 0.02;
const left = longitude - delta;
const right = longitude + delta;
const top = latitude + delta;
const bottom = latitude - delta;
return `https://www.openstreetmap.org/export/embed.html?bbox=${left}%2C${bottom}%2C${right}%2C${top}&layer=mapnik&marker=${latitude}%2C${longitude}`;
}, [locationCoords]);

const handleToastDemo = () => {
// Demonstrate different toast variants
Expand Down Expand Up @@ -69,6 +86,42 @@ export function Profile() {
return null;
}

const handleLocationBlockedDemo = async () => {
try {
const response = await ctrlConnector.controller.openLocationPrompt();
if (!response) {
return;
}

if (
response.code !== ResponseCodes.SUCCESS ||
!("location" in response)
) {
toast({
variant: "error",
message: response.message || "Location verification canceled",
});
return;
}

setLocationCoords({
latitude: response.location.latitude,
longitude: response.location.longitude,
});
toast({
variant: "transaction",
status: "confirmed",
isExpanded: true,
});
} catch (error) {
console.error("Location prompt failed:", error);
toast({
variant: "error",
message: "Unable to verify location",
});
}
};

return (
<div className="flex flex-col gap-4">
<h2>Open Starterpack</h2>
Expand All @@ -87,6 +140,31 @@ export function Profile() {
</div>
</div>

<h2>Location Prompt (Blocked Demo)</h2>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
<Button onClick={handleLocationBlockedDemo}>Verify Location</Button>
</div>
{locationCoords && (
<div className="flex flex-col gap-2 text-sm text-foreground-300">
<div>
Location received: lat {locationCoords.latitude.toFixed(5)}, lon{" "}
{locationCoords.longitude.toFixed(5)}
</div>
{mapUrl && (
<div className="overflow-hidden rounded-xl border border-foreground-700">
<iframe
title="Location map"
src={mapUrl}
className="h-56 w-full"
loading="lazy"
/>
</div>
)}
</div>
)}
</div>

<h2>Open Profile</h2>
<div className="flex flex-col gap-1">
<div className="flex flex-wrap gap-1">
Expand Down
18 changes: 16 additions & 2 deletions packages/connector/src/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ControllerProvider, {
AuthOptions,
ConnectOptions,
ControllerOptions,
} from "@cartridge/controller";
import { Connector, InjectedConnector } from "@starknet-react/core";
Expand Down Expand Up @@ -39,8 +40,21 @@ export default class ControllerConnector extends InjectedConnector {
return await this.controller.delegateAccount();
}

async connect(args?: { chainIdHint?: bigint; signupOptions?: AuthOptions }) {
const account = await this.controller.connect(args?.signupOptions);
async connect(args?: {
chainIdHint?: bigint;
signupOptions?: AuthOptions;
locationGate?: ConnectOptions["locationGate"];
connectOptions?: ConnectOptions;
}) {
const connectOptions =
args?.connectOptions ??
(args?.signupOptions || args?.locationGate
? {
signupOptions: args?.signupOptions,
locationGate: args?.locationGate,
}
: undefined);
const account = await this.controller.connect(connectOptions);
if (!account) {
throw new Error("Failed to connect controller");
}
Expand Down
56 changes: 52 additions & 4 deletions packages/controller/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
ProfileContextTypeVariant,
ResponseCodes,
OpenOptions,
LocationPromptOptions,
ConnectOptions,
StarterpackOptions,
} from "./types";
import { validateRedirectUrl } from "./url-validator";
Expand All @@ -43,6 +45,29 @@ export default class ControllerProvider extends BaseProvider {
return !!this.keychain;
}

private normalizeConnectOptions(
options?: AuthOptions | ConnectOptions,
): ConnectOptions {
if (Array.isArray(options)) {
return {
signupOptions: options,
locationGate: this.options.locationGate,
};
}

if (options && typeof options === "object") {
return {
signupOptions: options.signupOptions ?? this.options.signupOptions,
locationGate: options.locationGate ?? this.options.locationGate,
};
}

return {
signupOptions: this.options.signupOptions,
locationGate: this.options.locationGate,
};
}

constructor(options: ControllerOptions = {}) {
super();

Expand Down Expand Up @@ -226,7 +251,7 @@ export default class ControllerProvider extends BaseProvider {
}

async connect(
signupOptions?: AuthOptions,
options?: AuthOptions | ConnectOptions,
): Promise<WalletAccount | undefined> {
if (!this.iframes) {
return;
Expand All @@ -251,9 +276,12 @@ export default class ControllerProvider extends BaseProvider {
this.iframes.keychain.open();

try {
// Use connect() parameter if provided, otherwise fall back to constructor options
const effectiveOptions = signupOptions ?? this.options.signupOptions;
let response = await this.keychain.connect(effectiveOptions);
const effectiveOptions = this.normalizeConnectOptions(options);
const connectPayload =
effectiveOptions.signupOptions || effectiveOptions.locationGate
? effectiveOptions
: undefined;
let response = await this.keychain.connect(connectPayload);
if (response.code !== ResponseCodes.SUCCESS) {
throw new Error(response.message);
}
Expand Down Expand Up @@ -432,6 +460,26 @@ export default class ControllerProvider extends BaseProvider {
return this.keychain.username();
}

async openLocationPrompt(options?: LocationPromptOptions) {
if (!this.iframes) {
return;
}

if (!this.keychain || !this.iframes.keychain) {
console.error(new NotReadyToConnect().message);
return;
}

const responsePromise = this.keychain.openLocationPrompt(options);
this.iframes.keychain.open();

try {
return await responsePromise;
} finally {
this.iframes.keychain.close();
}
}

openPurchaseCredits() {
if (!this.iframes) {
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/controller/src/iframe/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class IFrame<CallSender extends {}> implements Modal {
iframe.sandbox.add("allow-scripts");
iframe.sandbox.add("allow-same-origin");
iframe.allow =
"publickey-credentials-create *; publickey-credentials-get *; clipboard-write; local-network-access *; payment *";
"publickey-credentials-create *; publickey-credentials-get *; clipboard-write; geolocation *; local-network-access *; payment *";
iframe.style.scrollbarWidth = "none";
iframe.style.setProperty("-ms-overflow-style", "none");
iframe.style.setProperty("-webkit-scrollbar", "none");
Expand Down
44 changes: 43 additions & 1 deletion packages/controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,36 @@ export type DeployReply = {
transaction_hash: string;
};

export type LocationCoordinates = {
latitude: number;
longitude: number;
accuracy: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
timestamp: number;
};

export type LocationPromptReply = {
code: ResponseCodes.SUCCESS;
location: LocationCoordinates;
};

export type LocationGateOptions = {
/** ISO 3166-1 alpha-2 country codes, e.g. "US", "CA". */
allowedCountries?: string[];
/** ISO 3166-2 region codes, e.g. "US-CA". */
allowedRegions?: string[];
/** US state abbreviations or full names, e.g. "CA" or "California". */
allowedStates?: string[];
};

export type ConnectOptions = {
signupOptions?: AuthOptions;
locationGate?: LocationGateOptions;
};

export type IFrames = {
keychain?: KeychainIFrame;
version?: number;
Expand Down Expand Up @@ -131,7 +161,9 @@ export type ControllerAccounts = Record<ContractAddress, CartridgeID>;

export interface Keychain {
probe(rpcUrl: string): Promise<ProbeReply | ConnectError>;
connect(signupOptions?: AuthOptions): Promise<ConnectReply | ConnectError>;
connect(
options?: AuthOptions | ConnectOptions,
): Promise<ConnectReply | ConnectError>;
disconnect(): void;

reset(): void;
Expand Down Expand Up @@ -160,6 +192,9 @@ export interface Keychain {
username(): string;
openPurchaseCredits(): void;
openExecute(calls: Call[]): Promise<void>;
openLocationPrompt(
options?: LocationPromptOptions,
): Promise<LocationPromptReply | ConnectError>;
switchChain(rpcUrl: string): Promise<void>;
openStarterPack(
id: string | number,
Expand Down Expand Up @@ -239,6 +274,8 @@ export type KeychainOptions = IFrameOptions & {
feeSource?: FeeSource;
/** Signup options (the order of the options is reflected in the UI. It's recommended to group socials and wallets together ) */
signupOptions?: AuthOptions;
/** Optional location gating to enforce allowed regions before connect. */
locationGate?: LocationGateOptions;
/** When true, manually provided policies will override preset policies. Default is false. */
shouldOverridePresetPolicies?: boolean;
/** The project name of Slot instance. */
Expand Down Expand Up @@ -270,6 +307,11 @@ export type OpenOptions = {
redirectUrl?: string;
};

export type LocationPromptOptions = {
/** Optional path to navigate to after completion (standalone mode). */
returnTo?: string;
};

export type StarterpackOptions = {
/** The preimage to use */
preimage?: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/keychain/src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { CollectibleListing } from "./inventory/collection/collectible-listing";
import { CollectiblePurchase } from "./inventory/collection/collectible-purchase";
import { Execute } from "./Execute";
import { SignMessage } from "./SignMessage";
import { LocationGate } from "./location/LocationGate";
import { LocationPrompt } from "./location/LocationPrompt";
import { ConnectRoute } from "./ConnectRoute";
import { Funding } from "./funding";
import { Deposit } from "./funding/Deposit";
Expand Down Expand Up @@ -290,6 +292,8 @@ export function App() {
/>
<Route path="/execute" element={<Execute />} />
<Route path="/sign-message" element={<SignMessage />} />
<Route path="/location-gate" element={<LocationGate />} />
<Route path="/location" element={<LocationPrompt />} />
<Route path="/deploy" element={<DeployController />} />
<Route path="/connect" element={<ConnectRoute />} />
<Route path="/feature/:name/:action" element={<FeatureToggle />} />
Expand Down
Loading
Loading