diff --git a/client-app/README.md b/client-app/README.md new file mode 100644 index 0000000..76b0fff --- /dev/null +++ b/client-app/README.md @@ -0,0 +1,73 @@ +# Quote of the Day + +A react application showing how to load a **variant feature flag** from **Azure App Configuration** via **Azure Front Door** and use it to experiment with a personalized greeting in a classic Quote of the Day UI. + +## Prerequisites +* Node.js 18+ +* Azure Front Door endpoint configured with Azure App Configuration as its origin. +* Application Insights resource + +## Get Started + +- Replace the placeholders in `src/config.js`: + ```js + // Application Insights connection string (Instrumentation for telemetry) + appInsightsConnectionString: "YOUR-APP-INSIGHTS-CONNECTION-STRING", + // Azure Front Door endpoint hosting App Configuration data (feature flags, etc.) + azureFrontDoorEndpoint: "YOUR-AZURE-FRONT-DOOR-ENDPOINT" + ``` + +- Create a variant feature flag named `Greeting` in your App Configuration store with the below configuration: + + ```json + { + "id": "Greeting", + "enabled": true, + "variants": [ + { + "name": "Off", + "configuration_value": false + }, + { + "name": "On", + "configuration_value": true + } + ], + "allocation": { + "percentile": [ + { + "variant": "Off", + "from": 0, + "to": 50 + }, + { + "variant": "On", + "from": 50, + "to": 100 + } + ], + "user": [ + { + "variant": "On", + "users": [ + "admin" + ] + } + ], + "default_when_enabled": "Off", + "default_when_disabled": "Off" + }, + "telemetry": { + "enabled": true + } + } + ``` +- Run the following command: + + ```powershell + cd client-app + npm install + npm run build + npm run preview + ``` + diff --git a/client-app/index.html b/client-app/index.html new file mode 100644 index 0000000..149e009 --- /dev/null +++ b/client-app/index.html @@ -0,0 +1,13 @@ + + + + + + + Quote of the Day + + +
+ + + diff --git a/client-app/package.json b/client-app/package.json new file mode 100644 index 0000000..4a345db --- /dev/null +++ b/client-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "quote-of-the-day", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@azure/app-configuration-provider": "2.3.0-preview", + "@microsoft/feature-management": "latest", + "@microsoft/feature-management-applicationinsights-browser": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-icons": "5.3.0", + "react-router-dom": "^6.27.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^7.2.2" + } +} diff --git a/client-app/public/vite.svg b/client-app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/client-app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client-app/src/App.css b/client-app/src/App.css new file mode 100644 index 0000000..0df6d08 --- /dev/null +++ b/client-app/src/App.css @@ -0,0 +1,198 @@ +body { + margin: 0; + font-family: 'Georgia', serif; +} + +.quote-page { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #f4f4f4; +} + +.navbar { + background-color: white; + border-bottom: 1px solid #eaeaea; + display: flex; + justify-content: space-between; + padding: 10px 20px; + align-items: center; + font-family: 'Arial', sans-serif; + font-size: 16px; +} + +.navbar-left { + display: flex; + align-items: center; + margin-left: 40px; +} + +.logo { + font-size: 1.25em; + text-decoration: none; + color: black; + margin-right: 20px; +} + +.navbar-left nav a { + margin-right: 20px; + text-decoration: none; + color: black; + font-weight: 500; + font-family: 'Arial', sans-serif; +} + +.navbar-right a { + margin-left: 20px; + text-decoration: none; + color: black; + font-weight: 500; + font-family: 'Arial', sans-serif; +} + +.quote-container { + display: flex; + justify-content: center; + align-items: center; + flex-grow: 1; +} + +.quote-card { + background-color: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + max-width: 700px; + position: relative; + text-align: left; +} + +.quote-card h2 { + font-weight: normal; +} + +.quote-card blockquote { + font-size: 2em; + font-family: 'Georgia', serif; + font-style: italic; + color: #4EC2F7; + margin: 0 0 20px 0; + line-height: 1.4; + text-align: left; +} + +.quote-card footer { + font-size: 0.55em; + color: black; + font-family: 'Arial', sans-serif; + font-style: normal; + text-align: left; + font-weight: bold; +} + +.vote-container { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 0em; +} + +.heart-button { + background-color: transparent; + border: none; + cursor: pointer; + padding: 5px; + font-size: 24px; +} + +.heart-button:hover { + background-color: #F0F0F0; +} + +.heart-button:focus { + outline: none; + box-shadow: none; +} + +footer { + background-color: white; + padding-top: 10px; + text-align: center; + border-top: 1px solid #eaeaea; +} + +footer a { + color: #4EC2F7; + text-decoration: none; +} + +.register-login-card { + width: 300px; + margin: 50px auto; + padding: 20px; + border-radius: 8px; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); + background-color: #ffffff; + text-align: center; +} + +h2 { + margin-bottom: 20px; + color: #333; +} + +.input-container { + margin-bottom: 15px; + text-align: left; + width: 100%; /* Ensure the container takes the full width */ +} + +label { + display: block; + margin-bottom: 5px; + font-size: 14px; + color: #555; +} + +input { + width: calc(100%); /* Add padding for both left and right */ + padding: 10px; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 14px; + box-sizing: border-box; /* Ensure padding doesn't affect the width */ +} + +input:focus { + outline: none; + border-color: #007bff; +} + +.register-login-button { + width: 100%; + padding: 10px; + background-color: #007bff; + border: none; + border-radius: 4px; + color: white; + font-size: 16px; + cursor: pointer; + margin-top: 10px; +} + +.register-login-button:hover { + background-color: #0056b3; +} + +.error-message { + color: red; +} + +.logout-btn { + margin-left: 20px; + background-color: transparent; + border: none; + cursor: pointer; + font-size: 16px; +} \ No newline at end of file diff --git a/client-app/src/App.jsx b/client-app/src/App.jsx new file mode 100644 index 0000000..4e5014d --- /dev/null +++ b/client-app/src/App.jsx @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; +import { ContextProvider } from "./pages/AppContext"; +import Layout from "./Layout"; +import Home from "./pages/Home"; +import Register from "./pages/Register"; +import Login from "./pages/Login"; + + +function App() { + return ( + + + + + } /> + } /> + } /> + + + + + ); +} + +export default App; diff --git a/client-app/src/Layout.jsx b/client-app/src/Layout.jsx new file mode 100644 index 0000000..b6a3c2b --- /dev/null +++ b/client-app/src/Layout.jsx @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useContext } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { AppContext } from "./pages/AppContext"; + +const Layout = ({ children }) => { + const { currentUser, logoutUser } = useContext(AppContext); + const navigate = useNavigate(); + + const handleLogout = () => { + logoutUser(); + navigate("/"); + }; + + return ( +
+
+
+ QuoteOfTheDay + +
+
+ {currentUser ? + ( + <> + Hello, {currentUser}! + + + ) : + ( + <> + Register + Login + + ) + } +
+
+ +
+ {children} +
+ + +
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/client-app/src/config.js b/client-app/src/config.js new file mode 100644 index 0000000..1dac8e1 --- /dev/null +++ b/client-app/src/config.js @@ -0,0 +1,6 @@ +export const config = { + // Application Insights connection string (Instrumentation for telemetry) + appInsightsConnectionString: "YOUR-APP-INSIGHTS-CONNECTION-STRING", + // Azure Front Door endpoint hosting App Configuration data (feature flags, etc.) + azureFrontDoorEndpoint: "YOUR-AZURE-FRONT-DOOR-ENDPOINT" +}; \ No newline at end of file diff --git a/client-app/src/index.jsx b/client-app/src/index.jsx new file mode 100644 index 0000000..dc1ea2f --- /dev/null +++ b/client-app/src/index.jsx @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./App.css"; + +window.addEventListener("beforeunload", (event) => { + // clear the localStorage when the user leaves the page + localStorage.clear() +}); + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render( + + + +); \ No newline at end of file diff --git a/client-app/src/pages/AppContext.jsx b/client-app/src/pages/AppContext.jsx new file mode 100644 index 0000000..083eccc --- /dev/null +++ b/client-app/src/pages/AppContext.jsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { config } from "../config"; + +import { createContext, useState, useEffect } from "react"; +import { loadFromAzureFrontDoor } from "@azure/app-configuration-provider"; +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "@microsoft/feature-management"; +import { createTelemetryPublisher } from "@microsoft/feature-management-applicationinsights-browser"; +import { ApplicationInsights } from "@microsoft/applicationinsights-web"; + +export const AppContext = createContext(); + +export const ContextProvider = ({ children }) => { + const [currentUser, setCurrentUser] = useState(undefined); + const [featureManager, setFeatureManager] = useState(undefined); + const appInsights = new ApplicationInsights({ config: { + connectionString: config.appInsightsConnectionString, + }}); + appInsights.loadAppInsights(); + + useEffect(() => { + const init = async () => { + const appConfig = await loadFromAzureFrontDoor( + config.azureFrontDoorEndpoint, + { + featureFlagOptions: { + enabled: true + } + } + ); + + const fm = new FeatureManager( + new ConfigurationMapFeatureFlagProvider(appConfig), + {onFeatureEvaluated: createTelemetryPublisher(appInsights)} + ); + setFeatureManager(fm); + }; + + init(); + }, []); + + const loginUser = (user) => { + setCurrentUser(user); + }; + + const logoutUser = () => { + setCurrentUser(undefined); + }; + + return ( + + {children} + + ); +}; diff --git a/client-app/src/pages/Home.jsx b/client-app/src/pages/Home.jsx new file mode 100644 index 0000000..56c79ef --- /dev/null +++ b/client-app/src/pages/Home.jsx @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useState, useEffect, useContext } from "react"; +import { FaHeart, FaRegHeart } from "react-icons/fa"; +import { trackEvent } from "@microsoft/feature-management-applicationinsights-browser"; +import { AppContext } from "./AppContext"; + +function Home() { + const { appInsights, appConfig, featureManager, currentUser, lastRefresh } = useContext(AppContext); + const [liked, setLiked] = useState(false); + const [variant, setVariant] = useState(undefined); + + const quote = { + text: "The only way to do great work is to love what you do.", + author: "Steve Jobs" + }; + + useEffect(() => { + const init = async () => { + const variant = await featureManager?.getVariant("Greeting", { userId: currentUser }); + setVariant(variant); + setLiked(false); + }; + + init(); + }, [appConfig, featureManager, currentUser, lastRefresh]); + + const handleLike = () => { + if (!liked) { + const targetingId = currentUser; + trackEvent(appInsights, targetingId, { name: "Like" }); + } + setLiked(!liked); + }; + + return ( +
+ { variant ? + ( + <> +

+ { variant.name === "On" ? + ( <>Hi {currentUser ?? "Guest"}, hope this makes your day! ) : + ( <>Quote of the day ) } +

+
+

"You cannot change what you are, only what you do."

+
— Philip Pullman
+
+
+ +
+ + ) + :

Loading

+ } +
+ ); +} + +export default Home; \ No newline at end of file diff --git a/client-app/src/pages/Login.jsx b/client-app/src/pages/Login.jsx new file mode 100644 index 0000000..fdb3aad --- /dev/null +++ b/client-app/src/pages/Login.jsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useState, useContext } from "react"; +import { useNavigate } from "react-router-dom"; + +import { AppContext } from "./AppContext"; + +const Login = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + + const { loginUser } = useContext(AppContext); + const navigate = useNavigate(); + + const handleLogin = (e) => { + + e.preventDefault(); + + // Retrieve user from localStorage + const users = JSON.parse(localStorage.getItem("users")) || []; + const user = users.find((user) => user.username === username && user.password === password); + + if (user) { + loginUser(username); + navigate("/"); + } + else { + setMessage("Invalid username or password!"); + } + }; + + return ( +
+

Login

+
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+

{message}

+
+
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/client-app/src/pages/Register.jsx b/client-app/src/pages/Register.jsx new file mode 100644 index 0000000..05d6fdf --- /dev/null +++ b/client-app/src/pages/Register.jsx @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useState, useContext } from "react"; +import { useNavigate } from "react-router-dom"; + +import { AppContext } from "./AppContext"; + +const Register = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + + const { loginUser } = useContext(AppContext); + const navigate = useNavigate(); + + const handleRegister = (e) => { + + e.preventDefault(); + + const users = JSON.parse(localStorage.getItem("users")) || []; + const existingUser = users.some((user) => (user.username === username)); + + if (existingUser) { + setMessage("User already exists!"); + } + else { + users.push({ username, password }); + localStorage.setItem("users", JSON.stringify(users)); + loginUser(username); + navigate("/"); + } + }; + + return ( +
+

Register

+
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+

{message}

+
+
+ ); +}; + +export default Register; \ No newline at end of file diff --git a/client-app/vite.config.js b/client-app/vite.config.js new file mode 100644 index 0000000..ac6bd5b --- /dev/null +++ b/client-app/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()] +})