Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Metrics milestone #55

Merged
merged 19 commits into from
Apr 18, 2024
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: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ To start the application, simply run `npm run dev`. You can access the applicati
| `.all-contributorsrc` | See <https://allcontributors.org>. |
| `.env` | Non-secret environment variables. |
| `jsconfig.json` | VS Code IntelliSense support file. |
| `public/` | Public assets (fonts) |

Other supporting files in the root directory include configuration files for linting (ESLint + Prettier), testing (Jest), Next.js, and others.

Expand Down Expand Up @@ -127,6 +128,18 @@ The application is currently deployed via Vercel at [http://unify-cs439.vercel.a

You can view an example of a User Profile page at [http://unify-cs439.vercel.app/user/testuser](http://unify-cs439.vercel.app/user/testuser). You can view an example of a Unify Results page at [http://unify-cs439.vercel.app/unify/testuser&hoixw](http://unify-cs439.vercel.app/unify/testuser&hoixw).

## Metrics Milestone

We have chosen to use a multi armed bandit approach to find what text for the "share" button on the user page
will result in the highest rate of the user sharing their results. 10% of the time, the user will be displayed a random
choice of the three possible texts, this is the exploration part. The other 90% of the time, the user will be displayed the text that has the highest conversion rate thus far.\
The three button texts that the user could be shown are:\
Share Results\
Share Cassette\
Share with Friends!\
We believe that this button is an appropriate element to implement the multi armed bandit procedure, as it is what drives growth of our application. New users will click this button to unify with their friends, and be prompted to create an account, increasing the reach of our application.
The code for calculating which option to use can be found in /src/app/user/[slug]/page.jsx. It is a react effect that pulls the data from the supabase database to find the best version of the design, or randomly chooses one. This gets passed to /src/app/user/[slug]/Boombox.jsx, where the text of the button is changed depending on the design selected.

## Contributors

<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
Expand Down
18 changes: 17 additions & 1 deletion __tests__/share_cassette.t.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ jest.mock("../src/utils/supabase/client", () => {
return jest.fn(() => ({
from: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
then: jest.fn().mockReturnThis(),
catch: jest.fn().mockReturnThis(),
eq: jest.fn().mockResolvedValue({
data: [
{
Expand Down Expand Up @@ -69,12 +72,25 @@ afterEach(() => {
});

describe("shareCassette", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
it("should attempt to share using navigator.share when image is ready", async () => {
render(<UserPage params={{ slug: "testslug" }} />);

// Wait for the button with specific text and style to appear in the document
const shareButton = await screen.findByRole("button", {
name: /share cassette/i,
name: /share/i,
});

fireEvent.click(shareButton);
Expand Down
13 changes: 13 additions & 0 deletions __tests__/share_unify.t.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ afterEach(() => {
});

describe("shareCassette", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
it("should attempt to share using navigator.share when image is ready", async () => {
render(<UnifyContent user1Data={userData} user2Data={userData} />);

Expand Down
26 changes: 26 additions & 0 deletions __tests__/unify.t.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ describe("featureDataSimilarity", () => {
});

describe("VinylCircle Component", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
test("renders correctly with given props", () => {
const { getByTestId } = render(
<VinylCircle centerCircleColor="black" width={300} />,
Expand Down Expand Up @@ -110,6 +123,19 @@ describe("GenrePieChart Component", () => {
});

describe("UnifyContent", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
it("renders UnifyContent correctly with provided data", () => {
render(<UnifyContent user1Data={userData} user2Data={userData} />);
});
Expand Down
2 changes: 2 additions & 0 deletions __tests__/user.t.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ beforeEach(() => {
createClient.mockImplementation(() => ({
from: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
then: jest.fn().mockReturnThis(),
catch: jest.fn().mockReturnThis(),
eq: jest.fn().mockResolvedValue({
data: [{ spotify_data: { username: "user1" } }],
error: null,
Expand Down
26 changes: 26 additions & 0 deletions __tests__/user_content.t.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ describe("VinylCircle Component", () => {
});

describe("GenrePieChart Component", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
const mockData = [
{ id: "pov: indie", value: 21 },
{ id: "modern rock", value: 16 },
Expand All @@ -61,6 +74,19 @@ describe("GenrePieChart Component", () => {
const mockShareCassette = jest.fn();

describe("UnifyContent", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
it("renders UnifyContent correctly with provided data", () => {
render(
<UserContent userData={userData} shareCassette={mockShareCassette} />,
Expand Down
Binary file added public/fonts/HomemadeApple.ttf
Binary file not shown.
Binary file added public/fonts/Koulen-Regular.ttf
Binary file not shown.
2 changes: 2 additions & 0 deletions src/app/IPod.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function Ipod({ children }) {
stroke="black"
strokeWidth="5"
/>
{/* add in children as foreign object */}
<foreignObject x="99.5" y="87.5" width="444" height="277">
<div xmlns="http://www.w3.org/1999/xhtml">{children}</div>
</foreignObject>
Expand Down Expand Up @@ -106,6 +107,7 @@ export default function Ipod({ children }) {
fill="#D3D3D3"
/>
</g>
{/* this next part is just paper texture stuff(?) using feComposite */}
<defs>
<filter
id="filter0_d_157_54"
Expand Down
2 changes: 2 additions & 0 deletions src/app/LoadingIcon.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/*
General purpose loading icon (simple SVG cassette animation).
Used when loading from home page to user page on sign in
*/

import React from "react";

export default function LoadingIcon() {
Expand Down
8 changes: 8 additions & 0 deletions src/app/auth/callback/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@ import createClient from "@/utils/supabase/server";
import { getSpotifyData } from "@/spotify";

export async function GET(request) {
// get code from current url's searchParams
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");

// redirect and delete code from search params
const redirectTo = request.nextUrl.clone();
redirectTo.searchParams.delete("code");

// run if the function gets a code back from supabase (used during PKCE oauth flow)
if (code) {
// create supabase client
const supabase = createClient();

// logs the user in using supabase using the code that gets issued when returning from spotify
const { data, error } = await supabase.auth.exchangeCodeForSession(code);

if (error) {
// redirect to error page
redirectTo.pathname = "/error";
redirectTo.searchParams.set("message", error.message);
return NextResponse.redirect(redirectTo);
Expand All @@ -37,6 +42,7 @@ export async function GET(request) {
try {
spotifyUserData = await getSpotifyData(data.session.provider_token);
} catch (e) {
// redirect to error page
redirectTo.pathname = "/error";
redirectTo.searchParams.set("message", e.message || e);
return NextResponse.redirect(redirectTo);
Expand All @@ -52,6 +58,7 @@ export async function GET(request) {
.eq("id", data.user.id);

if (dbError) {
// redirect to error page
redirectTo.pathname = "/error";
redirectTo.searchParams.set("message", dbError.message || dbError);
return NextResponse.redirect(redirectTo);
Expand All @@ -62,6 +69,7 @@ export async function GET(request) {
return NextResponse.redirect(redirectTo);
}

// redirect to home page of app
redirectTo.pathname = "/";
return NextResponse.redirect(redirectTo);
}
22 changes: 14 additions & 8 deletions src/app/auth/confirm/route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/*
Route that the user gets sent to after clicking the link from the Supabase confirmation email.
It validates the one-time password (OTP) from the link, then redirects the user accordingly.
If the OTP is valid, it redirects to a specified or default path. if not, it sends the user to an error page.
*/

// https://supabase.com/docs/guides/auth/server-side/nextjs
Expand All @@ -9,27 +11,31 @@ import { NextResponse } from "next/server";
import createClient from "@/utils/supabase/server";

export async function GET(request) {
// get search params from current url
const { searchParams } = new URL(request.url);
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type");
const next = searchParams.get("next") ?? "/";
const tokenHash = searchParams.get("token_hash"); // get the token hash from URL
const type = searchParams.get("type"); // get the type of request from URL
const next = searchParams.get("next") ?? "/"; // get the redirect path or use default

const redirectTo = request.nextUrl.clone();
redirectTo.pathname = next;
redirectTo.searchParams.delete("token_hash");
redirectTo.searchParams.delete("type");
const redirectTo = request.nextUrl.clone(); // clone the request URL for modification
redirectTo.pathname = next; // set the redirect pathname
redirectTo.searchParams.delete("token_hash"); // remove token_hash from URL
redirectTo.searchParams.delete("type"); // remove type from URL

if (tokenHash && type) {
const supabase = createClient();
const supabase = createClient(); // create a new Supabase client

// try to verify the OTP
const { error } = await supabase.auth.verifyOtp({
type,
token_hash: tokenHash,
});
if (!error) {
// remove the 'next' parameter if no error
redirectTo.searchParams.delete("next");
return NextResponse.redirect(redirectTo);
}
// set error message in URL if there's an error
redirectTo.searchParams.set("error", error.message);
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/auth/signout/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NextResponse } from "next/server";
import createClient from "@/utils/supabase/server";

export async function POST(req) {
// create supabase client
const supabase = createClient();

// Check if a user's logged in
Expand All @@ -21,6 +22,7 @@ export async function POST(req) {
await supabase.auth.signOut();
}

// redirect to home page
revalidatePath("/", "layout");
return NextResponse.redirect(new URL("/", req.url), {
status: 302,
Expand Down
2 changes: 2 additions & 0 deletions src/app/error/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { useSearchParams } from "next/navigation";
import ErrorAlert from "@/app/error/error";

function SuspendedErrorAlert() {
// get error message from search params
const searchParams = useSearchParams();
const error = searchParams.get("error");

// use errorAlert to display the error message from the search params
return <ErrorAlert Title={"Error"} Message={error || "An error occured."} />;
}

Expand Down
4 changes: 2 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ ol li {

@font-face {
font-family: "Koulen";
src: url("https://github.com/danhhong/Koulen/raw/master/Release/ttf/Koulen-Regular.ttf");
src: url("/fonts/Koulen-Regular.ttf") format("truetype");
}

@font-face {
font-family: "HomemadeApple";
src: url("https://github.com/d3y4n/fonterator/raw/master/static/fonts/Homemade%20Apple.ttf");
src: url("/fonts/HomemadeApple.ttf") format("truetype");
}
8 changes: 8 additions & 0 deletions src/app/layout.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
/*
Layout stuff for application setting Koulen as default font
*/

import PropTypes from "prop-types";
import "./globals.css";
import "bootstrap/dist/css/bootstrap.min.css";
import { koulen } from "@/fonts";

// this defines metadata with a title and an empty description
export const metadata = {
title: "Unify",
description: "",
};

// this function wraps its children components with HTML structure.
// it sets the language of the document to english and applies a custom font class to the body.
export default function RootLayout({ children }) {
return (
<html lang="en">
Expand Down
2 changes: 2 additions & 0 deletions src/app/login/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ export default async function loginWithSpotify() {
});

if (error) {
// redirects to error page on error
redirect(`/error?message=${error.message}`);
} else {
// redirects to url from supabase oauth to continue sign in (goes to spotify)
redirect(data.url);
}
}
5 changes: 5 additions & 0 deletions src/app/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import LeftPanel from "@/app/LeftPanel";
import LoadingIcon from "@/app/LoadingIcon";
import createClient from "@/utils/supabase/client";

// index page function
export default function IndexPage() {
// use react router for page navigation
const router = useRouter();
// create supabase client
const supabase = createClient();

const [loggedIn, setLoggedIn] = useState(false);
Expand Down Expand Up @@ -97,9 +100,11 @@ export default function IndexPage() {
loginWithSpotify();
}}
>
{/* Show Continue to Account if logged in, else, show log in with Spotify */}
{loggedIn ? "Continue to Account" : "Log in with Spotify"}
</button>
<div className={`${loggedIn ? "" : "hidden"}`}>
{/* if logged in show sign out button which calls handleSignOut when clicked */}
<button
className="border rounded-full bg-white px-5 py-3 text-3xl"
type="button"
Expand Down
Loading