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
72 changes: 71 additions & 1 deletion contracts/tic-tac-toe.clar
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
(define-constant ERR_GAME_NOT_FOUND u102) ;; Error thrown when a game cannot be found given a Game ID, i.e. invalid Game ID
(define-constant ERR_GAME_CANNOT_BE_JOINED u103) ;; Error thrown when a game cannot be joined, usually because it already has two players
(define-constant ERR_NOT_YOUR_TURN u104) ;; Error thrown when a player tries to make a move when it is not their turn
(define-constant ERR-USER-ALREADY-REGISTERED (err u113)) ;; user already registered
(define-constant ERR-USERNAME-EXISTS (err u112)) ;; username already taken

;; The Game ID to use for the next game
(define-data-var latest-game-id uint u0)
Expand All @@ -22,6 +24,73 @@
}
)

;; User registration: map principal to username and registration data
(define-map users
{ user: principal } ;; user principal
{
username: (string-utf8 50), ;; username (max 50 chars)
registered-at: uint, ;; block height when registered
}
)

;; Username to principal mapping (for uniqueness check)
(define-map usernames
{ username: (string-utf8 50) } ;; username
{ user: principal } ;; user principal
)

;; Get user registration data by principal
(define-read-only (get-user (user principal))
(map-get? users { user: user })
)

;; =============================================================================
;; PUBLIC FUNCTIONS - USER REGISTRATION
;; =============================================================================

;; register-user(username)
;; Purpose: Register a user with a unique username.
;; Params:
;; - username: (string-utf8 50) desired username (max 50 characters).
;; Preconditions:
;; - User (tx-sender) is not already registered.
;; - Username is not already taken by another user.
;; Effects:
;; - Creates user record in `users` map with username and registration timestamp.
;; - Creates reverse mapping in `usernames` map for uniqueness enforcement.
;; Events: Emits "user-registered" with user principal and username.
;; Returns: (ok true) on success, or appropriate error code.
(define-public (register-user (username (string-utf8 50)))
(let (
(existing-user (map-get? users { user: tx-sender }))
(existing-username (map-get? usernames { username: username }))
)
(begin
;; Validations
(asserts! (is-none existing-user) ERR-USER-ALREADY-REGISTERED)
(asserts! (is-none existing-username) ERR-USERNAME-EXISTS)

;; Register user
(map-set users { user: tx-sender } {
username: username,
registered-at: stacks-block-height,
})

;; Track username for uniqueness
(map-set usernames { username: username } { user: tx-sender })
;; Emit event
(print {
event: "user-registered",
user: tx-sender,
username: username,
})

(ok true)
)
)
)


(define-public (create-game (bet-amount uint) (move-index uint) (move uint))
(let (
;; Get the Game ID to use for creation of this new game
Expand Down Expand Up @@ -196,4 +265,5 @@

;; a-val must equal b-val and must also equal c-val while not being empty (non-zero)
(and (is-eq a-val b-val) (is-eq a-val c-val) (not (is-eq a-val u0)))
))
))

68 changes: 61 additions & 7 deletions frontend/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,27 @@
import { useStacks } from "@/hooks/use-stacks";
import { abbreviateAddress } from "@/lib/stx-utils";
import Link from "next/link";
import { useState, useRef, useEffect } from "react";

export function Navbar() {
const { userData, connectWallet, disconnectWallet } = useStacks();
const { userData, connectWallet, disconnectWallet, handleRegisterUser, usernameOnContract } = useStacks();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [username, setUsername] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);

// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}

document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);

return (
<nav className="flex w-full items-center justify-between gap-4 p-4 h-16 border-b border-gray-500">
Expand All @@ -25,12 +43,48 @@ export function Navbar() {
<div className="flex items-center gap-2">
{userData ? (
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{abbreviateAddress(userData.profile.stxAddress.testnet)}
</button>
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{usernameOnContract ? usernameOnContract : abbreviateAddress(userData.profile.stxAddress.testnet)}
</button>

{isDropdownOpen && usernameOnContract == "" && (
<div className="absolute right-0 mt-2 w-64 rounded-lg shadow-lg border border-gray-200 py-3 px-4 z-50">
<div className="space-y-3">
<div>
<label htmlFor="username" className="block text-sm font-medium text-white mb-1">
Register Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-black"
/>
</div>
<button
type="button"
onClick={async() => {
// TODO: Implement register username functionality
console.log("Registering username:", username);
await handleRegisterUser(username)
setIsDropdownOpen(false);
setUsername("");
}}
className="w-full bg-blue-500 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors duration-200"
>
Register Username
</button>
</div>
</div>
)}
</div>
<button
type="button"
onClick={disconnectWallet}
Expand Down
50 changes: 43 additions & 7 deletions frontend/hooks/use-stacks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createNewGame, joinGame, Move, play } from "@/lib/contract";
import { createNewGame, getUsername, joinGame, Move, play, registerUser } from "@/lib/contract";
import { getStxBalance } from "@/lib/stx-utils";
import {
AppConfig,
Expand All @@ -21,6 +21,7 @@ const userSession = new UserSession({ appConfig });
export function useStacks() {
const [userData, setUserData] = useState<UserData | null>(null);
const [stxBalance, setStxBalance] = useState(0);
const [usernameOnContract, setUsernameOnContract] = useState("");

function connectWallet() {
showConnect({
Expand Down Expand Up @@ -123,14 +124,47 @@ export function useStacks() {
}
}

useEffect(() => {
if (userSession.isSignInPending()) {
userSession.handlePendingSignIn().then((userData) => {
setUserData(userData);
async function handleRegisterUser(username: string) {
if (typeof window === "undefined") return;


try {
if (!userData) throw new Error("User not connected");
const txOptions = await registerUser(username);
await openContractCall({
...txOptions,
appDetails,
onFinish: (data) => {
console.log(data);
window.alert("Sent register user transaction");
},
postConditionMode: PostConditionMode.Allow,
});
} else if (userSession.isUserSignedIn()) {
setUserData(userSession.loadUserData());
} catch (_err) {
const err = _err as Error;
console.error(err);
window.alert(err.message);
}
}

useEffect(() => {
const loadData = async()=> {
if (userSession.isSignInPending()) {
userSession.handlePendingSignIn().then((userData) => {
setUserData(userData);
});
} else if (userSession.isUserSignedIn()) {
setUserData(userSession.loadUserData());
const _userData = userSession.loadUserData()
const address = _userData.profile.stxAddress.testnet;
const _username = await getUsername(address)
console.log("username is __", _username)
if(_username) setUsernameOnContract(String(_username))

}
}
loadData();

}, []);

useEffect(() => {
Expand All @@ -150,5 +184,7 @@ export function useStacks() {
handleCreateGame,
handleJoinGame,
handlePlayGame,
handleRegisterUser,
usernameOnContract
};
}
37 changes: 35 additions & 2 deletions frontend/lib/contract.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { STACKS_TESTNET } from "@stacks/network";
import {
BooleanCV,
cvToJSON,
cvToString,
cvToValue,
fetchCallReadOnlyFunction,
getCVTypeString,
ListCV,
OptionalCV,
PrincipalCV,
principalCV,
StringUtf8CV,
stringUtf8CV,
TupleCV,
uintCV,
UIntCV,
} from "@stacks/transactions";

const CONTRACT_ADDRESS = "ST3P49R8XXQWG69S66MZASYPTTGNDKK0WW32RRJDN";
const CONTRACT_NAME = "tic-tac-toe";
const CONTRACT_ADDRESS = "ST2N04CYE3CQ1S354MZX4KHYJYD4QW25ZW37GQY7J";
const CONTRACT_NAME = "tic";

type GameCV = {
"player-one": PrincipalCV;
Expand Down Expand Up @@ -146,3 +152,30 @@ export async function play(gameId: number, moveIndex: number, move: Move) {

return txOptions;
}

export async function registerUser(username: string) {
const txOptions = {
contractAddress: CONTRACT_ADDRESS,
contractName: CONTRACT_NAME,
functionName: "register-user",
functionArgs: [stringUtf8CV(username)],
};

return txOptions;
}

export async function getUsername(address: string) {
const username = (await fetchCallReadOnlyFunction({
contractAddress: CONTRACT_ADDRESS,
contractName: CONTRACT_NAME,
functionName: "get-user",
functionArgs: [principalCV(address)],
senderAddress: CONTRACT_ADDRESS,
network: STACKS_TESTNET,
})) as OptionalCV<TupleCV>;

if (username.type === "none") return null;

const userTuple = username.value.value as any;
return userTuple.username?.value || null;
}
Loading