refactor: restructure to monorepo with backends and apiTest

- Move web/backend to backends/potter-api
- Move web/frontend to backends/potter-api/frontend
- Add root package.json with npm workspaces
- Update README.md with monorepo navigation
- Update .github/workflows/api-tests.yml for dynamic test discovery
- Update bruno_test_scenario.md paths
- Fix .gitignore to allow JSON data files
- Add three new AI tests in apiTest/PotterAPI/AI/

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
kankys committed 2026-09-15 21:08:39 +02:00
1 parent 2e185b47ef
commit d0cdabbf11
57 files changed
+292 -35

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+12
View File
@@ -0,0 +1,12 @@
#build
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
#run
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
+24
View File
@@ -0,0 +1,24 @@
# frontend
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"start": "npm run serve"
},
"dependencies": {
"axios": "^0.27.0",
"core-js": "^3.6.4",
"url": "^0.11.0",
"vue": "^2.6.14",
"vue-router": "^3.5.3"
},
"devDependencies": {
"@babel/eslint-parser": "^7.5.4",
"@vue/cli-plugin-babel": "^5.0.4",
"@vue/cli-plugin-eslint": "~5.0.4",
"@vue/cli-service": "~5.0.4",
"eslint": "^8.14.0",
"eslint-plugin-vue": "^8.7.1",
"sass": "^1.50.1",
"sass-loader": "^12.6.0",
"vue-template-compiler": "^2.6.11",
"webpack": "^5.72.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Spelleology</title>
<link href="https://fonts.googleapis.com/css?family=Kanit:100i" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Lora:700" rel="stylesheet">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Allura&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@400&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div id="app">
<navigation />
<router-view></router-view>
</div>
</template>
<script>
import Navigation from "./components/Navigation";
export default {
name: "App",
components: {
Navigation
}
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
button.main {
padding: 10px 40px;
background-color: #822724;
color: white;
font-size: 1.2em;
cursor: pointer;
}
button {
border: none;
}
button:focus {
outline: none;
}
button.main:hover {
background-color: #72221f;
}
div.main {
padding-top: 50px;
min-height: 1000px;
}
</style>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1,121 @@
<template>
<div class="main">
<div class="container">
<div class="row">
<div class="col-2">
<img src="../assets/gringottsbanklogo.png" alt="" />
</div>
<div class="col-10">
<h1>Gringotts Bank</h1>
<h2>Investments for witches and wizards</h2>
</div>
</div>
<div class="row mt-5">
<div class="col mx-5">
<GringottsCalculator @calculation-done="handleCalculationDone" />
<GringottsForm
v-if="newInvestment"
:newInvestment="newInvestment"
@create-investment="handleCreateInvestment"
@reject-offer="handleRejectOffer"
/>
</div>
<div class="col mx-5">
<GringottsInvestmentList
:investments="investments"
@delete-investment="handleDeleteInvestment"
/>
<div v-if="investments.length === 0" class="no-investments-message">
<p>No investments yet. Make your first one!</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import GringottsCalculator from "../components/gringotts-bank/GringottsCalculator.vue";
import GringottsForm from "../components/gringotts-bank/GringottsForm.vue";
import GringottsInvestmentList from "../components/gringotts-bank/GringottsInvestmentList.vue";
export default {
components: {
GringottsCalculator,
GringottsForm,
GringottsInvestmentList,
},
created: function () {
if (localStorage.getItem("investments")) {
try {
this.investments = JSON.parse(localStorage.getItem("investments"));
} catch (e) {
localStorage.removeItem("investments");
}
}
},
data() {
return {
newInvestment: null,
investments: [],
};
},
methods: {
handleCalculationDone(newInvestmentData) {
this.newInvestment = newInvestmentData;
},
handleCreateInvestment(customerName) {
if (this.newInvestment) {
// Create a new investment object with customer name
const investment = {
...this.newInvestment,
customerName,
};
// Log the new investment to the console
console.log("New Investment:", investment);
this.investments.push(investment);
this.saveAppliedSavings();
// Optionally, reset the newInvestment data
this.newInvestment = null;
//this.$refs.calculator.resetInputFields();
}
},
handleRejectOffer() {
this.newInvestment = null;
// Handle the rejected offer, e.g., log a message or perform other actions
console.log("Offer Rejected");
},
handleDeleteInvestment(index) {
// Handle the deletion of the investment in the parent component
this.investments.splice(index, 1);
this.saveAppliedSavings();
},
saveAppliedSavings() {
const parsed = JSON.stringify(this.investments);
localStorage.setItem("investments", parsed);
},
},
};
</script>
<style scoped lang="scss">
div.main {
img {
width: 70%;
}
background-color: #eeedeb;
h1,
h2 {
color: #747264;
text-align: left;
}
p {
color: #868475;
text-align: left;
}
}
</style>
@@ -0,0 +1,113 @@
<template>
<div class="modal-mask">
<div class="modal-wrapper">
<div class="modal-container">
<div class="modal-header">
<h2 name="header">{{ spell.spell }}</h2>
</div>
<div class="modal-body">
<h3 name="body">{{ spell.effect }}</h3>
<h4 class="body">{{ spell.type }}</h4>
</div>
<div class="modal-footer">
<slot name="footer">
<button class="modal-default-button btn-block btn" @click="close">
Close
</button>
</slot>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "ModalComponent",
props: ["spell"],
methods: {
close() {
this.$emit("close");
},
},
};
</script>
<style scoped >
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: table;
transition: opacity 0.3s ease;
}
.modal-wrapper {
display: table-cell;
vertical-align: middle;
}
.modal-container {
text-align: center;
width: 900px;
margin: 0 auto;
padding: 20px 30px;
background-color: #822724;
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.2s ease-in-out;
font-family: Helvetica, Arial, sans-serif;
}
.modal-header h2 {
margin: 0 auto;
color: white;
font-family: "Harry", serif;
font-size: 6em;
}
.modal-body {
margin: 20px 0;
color: white;
font-family: "Lora", serif;
}
.modal-body h3 {
margin-bottom: 20px;
}
.modal-body h4 {
font-family: "Kanit", serif;
font-size: 1.5em;
}
.modal-enter {
opacity: 0;
}
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
-webkit-transform: scale(1.5);
transform: scale(1.5);
}
.modal-default-button {
font-family: "Harry", serif;
font-size: 2em;
color: white;
background-color: transparent;
}
.modal-default-button:active,
.modal-default-button:focus {
outline: none;
border: none;
box-shadow: none;
}
</style>
@@ -0,0 +1,43 @@
<template>
<nav class="navbar navbar-expand-lg">
<ul class="navbar-nav mx-auto">
<li class="nav-item">
<router-link class="nav-link" to="/">Welcome</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/spelleology"
>Spelleology</router-link
>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/sortingHat">Sorting Hat</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/quotes">Quote Generator</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/gringottsBank"
>Gringotts Bank</router-link
>
</li>
</ul>
</nav>
</template>
<script>
export default {
name: "NavigationComponent",
};
</script>
<style lang="scss">
a {
color: #212529;
&:hover {
color: #822724;
}
&.router-link-exact-active {
border-bottom: 1px solid #822724;
color: #822724;
}
}
</style>
@@ -0,0 +1,198 @@
<template>
<div class="main">
<h1 class="title">Potter Quotes</h1>
<h2 class="subtitle">Level up by getting some wisdom</h2>
<div class="quote-controls">
<button
v-on:click="getQuote"
class="main main mx-1"
data-test="get-quote"
>
Get Quote
</button>
<button
class="main mx-1"
v-on:click="removeQuote"
:disabled="quoteList.length === 0"
id="remove-quote"
>
Remove Quote
</button>
<div class="row wisdom-level col-md-10 mx-auto">
<div class="col">
<p data-test="wisdom-points">
wisdom points +{{ this.quoteList.length }}
</p>
</div>
<div class="col">
<p data-test="wisdom-level">current level {{ wizardLevel }}</p>
</div>
</div>
</div>
<div class="col-md-6 mx-auto">
<div class="quote-container">
<div v-if="this.qouteCounter == 0" class="empty-list-message">
Click the button to get some wisdom
</div>
<transition-group v-else name="fade" tag="ul" class="quote-list">
<li v-for="quoteitem in quoteList" :key="quoteitem.quote">
<p>{{ quoteitem.quote }}</p>
<p class="author">{{ quoteitem.author }}</p>
</li>
</transition-group>
</div>
</div>
</div>
</template>
<script>
export default {
name: "QuoteGenerator",
data: function () {
return {
quote: "",
quoteList: [],
qouteCounter: "",
};
},
methods: {
getQuote: function () {
this.quote = "";
this.$http.get("/quote").then((response) => {
this.quote = response.data;
this.quoteList.unshift(this.quote);
this.qouteCounter++;
});
},
removeQuote: function () {
if (this.quoteList.length > 0) {
this.quoteList.shift(); // Remove the newest quote
this.qouteCounter--;
}
},
},
computed: {
wizardLevel: function () {
let level = "";
if (this.qouteCounter <= 5) {
level = "NOOB";
}
if (this.qouteCounter > 5) {
level = "ROOKIE wand polisher";
}
if (this.qouteCounter > 10) {
level = "Experienced broom SUMMONER";
}
if (this.qouteCounter > 15) {
level = "NIGHTMARE Alchemist";
}
if (this.qouteCounter > 20) {
level = "Spell OVERLORD";
}
if (this.qouteCounter > 25) {
level = "Death potion EVANGELIST";
}
if (this.qouteCounter > 30) {
level = "Dementor the HAPPINESS SPECIALIST";
}
if (this.qouteCounter > 35) {
level = "Part-Time DEATHEATER";
}
if (this.qouteCounter > 40) {
level = "First Impression SPOILER";
}
if (this.qouteCounter > 45) {
level = "Azcaban INFLUENCER";
}
return level;
},
},
};
</script>
<style lang="scss" scoped>
$gray-color: #9b9a9a;
$red-color: #822724;
h1.title {
font-family: "Roboto Slab", serif;
}
h2.subtitle {
font-family: "Roboto Slab", serif;
color: $gray-color;
font-size: 1.5em;
}
button {
border: none;
cursor: pointer;
padding: 10px 30px;
margin-top: 20px;
background-color: $red-color;
}
button#remove-quote {
background-color: black;
}
button#remove-quote:disabled {
background-color: #9b9a9a;
cursor: not-allowed;
&:active {
transform: scale(1);
}
}
.quote-container {
margin-top: 50px;
ul {
list-style: none;
li {
font-family: "Roboto Slab", serif;
text-align: left;
padding: 20px;
margin-bottom: 10px;
box-shadow: 0px 0px 7px 1px rgba(230, 230, 230, 1);
&:nth-child(1) {
transform: scale(1.05);
margin-bottom: 20px;
box-shadow: 0px 0px 15px 3px #c17f7d;
}
.author {
color: $gray-color;
}
}
}
}
.fade-enter-to,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.quote-controls {
p {
display: inline-block;
color: $red-color;
font-weight: 700;
font-size: 1.5em;
text-align: justify;
}
button {
transition: all 200ms ease-in;
&:active {
transform: scale(1.1);
}
}
.wisdom-level {
margin-top: 20px;
p {
color: $gray-color;
font-weight: 300;
}
}
}
</style>
@@ -0,0 +1,73 @@
<template>
<input
type="text"
class="search"
placeholder="search for spell effect"
id="search"
data-test="search-input"
v-bind="$attrs"
v-bind:value="value"
v-on="inputListeners"
/>
</template>
<script>
export default {
name: "SearchComponent",
props: ["value"],
computed: {
inputListeners: function() {
var vm = this;
// `Object.assign` merges objects together to form a new object
return Object.assign(
{},
// We add all the listeners from the parent
this.$listeners,
// Then we can add custom listeners or override the
// behavior of some listeners.
{
// This ensures that the component works with v-model
input: function(event) {
vm.$emit("input", event.target.value);
}
}
);
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
input.search {
outline: none;
border: none;
color: #fff;
font-family: "Lora", serif;
border-bottom: 2px solid #dca39e;
background-color: #822724;
border-radius: 0;
font-size: 2em;
margin-bottom: 50px;
transition: all 200ms ease;
&:focus,
&:active {
outline: none;
box-shadow: none;
background-color: #822724;
border-radius: 0;
border-bottom: 2px solid #fff;
color: white;
}
&::placeholder {
color: #dca39e;
font-family: "Kanit", serif;
}
&:focus::placeholder {
color: #fff;
font-family: "Kanit", serif;
}
}
</style>
@@ -0,0 +1,97 @@
<template>
<div class="main">
<h1 class="title">Sorting Hat</h1>
<h2 class="subtitle">Find yourself a new home in Hogwarts</h2>
<button
class="main"
data-test="sort-button"
:disabled="disabled"
v-on:click="getQuote"
>{{sortButtonLabel}}</button>
<div class="row">
<div class="col-md-6">
<img class="hat" src="../assets/sortinghat.jpg" alt />
</div>
<div class="col-md-4 my-auto message">
<h2 v-if="quote" data-test="result-message">{{quote.sortingHatSays}}</h2>
<h1 v-if="quote" data-test="house-result">{{quote.house}}</h1>
<h3 class="message" v-else>{{loadingMessage}}</h3>
</div>
</div>
</div>
</template>
<script>
export default {
name: "SortingHat",
data: function() {
return {
disabled: false,
quote: "",
isSorted: false,
loadingMessage: "Click on sort to get yourself a new home"
};
},
computed: {
sortButtonLabel: function() {
if (this.isSorted) {
return "Sort me again!";
}
return "Sort me";
}
},
methods: {
getQuote: function() {
this.disabled = true;
this.loadingMessage = "Let me think...";
this.quote = "";
this.$http
.get("/sortingHat")
.then(response => (this.quote = response.data))
.then(() => {
(this.isSorted = true), (this.disabled = false);
});
}
}
};
</script>
<style lang="scss" scoped >
$gray-color: rgb(155, 154, 154);
div.main {
font-family: "Roboto Slab", serif;
h2.subtitle {
color: $gray-color;
font-size: 1.5em;
}
div.message {
h1 {
margin: 20px 0;
text-align: left;
color: #3f3f8c;
font-size: 3em;
}
h2 {
font-size: 1.9em;
text-align: left;
color: $gray-color;
}
.message {
text-align: center;
color: $gray-color;
}
}
.hat {
width: 60%;
}
button {
margin-top: 20px;
background-color: #3f3f8c;
&:hover {
background-color: #333373;
}
&:disabled {
background-color: $gray-color;
}
}
}
</style>
@@ -0,0 +1,56 @@
<template>
<div>
<ul class="spells row text-center">
<li
v-bind:key="spell.id"
v-for="spell in spells"
v-on:click="$emit('clickOnspell', spell)"
>
{{ spell.effect }}
</li>
</ul>
</div>
</template>
<script>
export default {
name: "SpellList",
props: ["spells"],
methods: {
clickOnspell: function () {
this.$emit("selectSpell");
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
.spells {
list-style: none;
padding: 20px;
li {
padding: 10px;
color: #dca39e;
font-family: "Lora", serif;
vertical-align: middle;
font-size: 2em;
cursor: pointer;
transition: all 200ms ease-in-out;
&:nth-child(1n + 2) {
font-size: 1.3em;
}
&:nth-child(2n + 3) {
font-size: 1.7em;
}
&:nth-child(3n) {
font-size: 2.2em;
}
&:hover {
color: #fffcf9;
}
}
}
</style>
@@ -0,0 +1,167 @@
<template>
<div class="main spells-main">
<h1 class="text-center title">Spelleology</h1>
<div class="col-md-12 mx-auto">
<div class="col-md-6 row mx-auto">
<search-component class="col" v-model="search" />
<div class="col-md-4 unforgivable-checkbox">
<input type="checkbox" id="unforgivableOnly" v-model="unforgivableOnly" />
<label for="unforgivableOnly">Unforgivable only</label>
</div>
</div>
<div class="type-filter">
<input type="checkbox" id="spell" value="Spell" v-model="checkedSpells" />
<label for="spell">Spell</label>
<input type="checkbox" id="charm" value="Charm" v-model="checkedSpells" />
<label for="charm">Charm</label>
<input type="checkbox" id="curse" value="Curse" v-model="checkedSpells" />
<label for="curse">Curse</label>
<input type="checkbox" id="enchantment" value="Enchantment" v-model="checkedSpells" />
<label for="enchantment">Enchantment</label>
<input type="checkbox" id="hex" value="Hex" v-model="checkedSpells" />
<label for="hex">Hex</label>
<input type="checkbox" id="jinx" value="Jinx" v-model="checkedSpells" />
<label for="jinx">Jinx</label>
</div>
<div>
<button
v-on:click="clearAll"
id="deleteAll"
data-test="delete-all"
class="spell-button"
>Delete all</button>
<button v-on:click="reset" id="resetSpells" data-test="reset" class="spell-button">Reset</button>
</div>
<div class="row col-md-12 mx-auto">
<spell-list
v-if="spells.length >=1"
:spells="filteredList"
v-on:clickOnspell="selectSpell"
/>
</div>
<h1
class="subtitle text-center"
v-if="spells.length ===0 || filteredList.length ===0"
>Mischief managed</h1>
<modal :spell="selectedSpell" v-show="showModal" @close="closeModal" />
</div>
</div>
</template>
<script>
import SpellList from "./SpellList.vue";
import SearchComponent from "./SearchComponent.vue";
import Modal from "./Modal.vue";
export default {
name: "SpelleologyComponent",
components: {
SpellList,
SearchComponent,
Modal,
},
data: function () {
return {
spells: [],
selectedSpell: "",
showModal: false,
search: "",
unforgivableOnly: false,
checkedSpells: ["Charm", "Enchantment", "Curse", "Spell", "Hex", "Jinx"],
};
},
computed: {
filteredList: function () {
let spells = this.spells;
spells = spells.filter((spell) =>
spell.effect.toLowerCase().includes(this.search.toLowerCase())
);
spells = spells.filter((spell) =>
this.checkedSpells.includes(spell.type)
);
if (this.unforgivableOnly) {
spells = spells.filter((spell) => spell.isUnforgivable === true);
}
return spells;
},
},
created: function () {
this.$http.get("/spells").then((response) => (this.spells = response.data));
},
methods: {
selectSpell: function (spell) {
this.selectedSpell = spell;
this.showModal = true;
},
closeModal() {
this.showModal = false;
},
clearAll: function () {
this.$http
.delete("spells/actions/deleteAll")
.then(() => (this.spells = []));
},
reset: function () {
this.$http.get("spells/actions/reset").then((response) => {
this.spells = response.data.spells;
});
},
},
};
</script>
<style lang="scss" scoped>
div.spells-main {
background-color: #822724;
}
h1 {
font-family: "Harry", serif;
&.title {
color: white;
font-size: 8em;
text-shadow: 7px 7px 2px #671e1c;
}
&.subtitle {
margin: 100px;
font-size: 6em;
color: #dca39e;
}
}
label {
color: white;
padding: 0 10px;
font-family: "Kanit", serif;
font-size: 1.5em;
}
div.unforgivable-checkbox {
padding: 20px 0;
}
.spell-button {
padding: 1px 20px;
background-color: #822724;
font-family: "Kanit", serif;
color: #fffcf9;
font-size: 2em;
cursor: pointer;
border-bottom: 2px solid #dca39e;
margin: 0 20px;
&:hover {
border-bottom: 2px solid white;
color: white;
}
}
@font-face {
font-family: "Harry";
src: url("../assets/HARRYP.TTF");
}
</style>
<!-- Add "scoped" attribute to limit CSS to this component only -->
@@ -0,0 +1,24 @@
<template>
<div>
<div class="welcome">
<h1>Welcome Wizard</h1><img src=../assets/hogwartslogo.png alt="">
</div>
</div>
</template>
<script>
export default {
name: "WelcomeComponent"
};
</script>
<style lang="scss" scoped>
div.welcome {
margin-top: 50px;
h1 {
font-size: 8em;
color: black;
font-family: "Roboto Slab", serif;
}
}
</style>
@@ -0,0 +1,172 @@
<template>
<div>
<h2>Enter your data muggle!</h2>
<div class="input-group mb-3">
<select class="custom-select" id="selectedFund" v-model="selectedFundId">
<option disabled value="">Please select fund</option>
<option
v-for="(product, index) in products"
:value="product.id"
:key="index"
>
{{ product.name }}
</option>
</select>
</div>
<div class="input-group mb-3">
<input
class="form-control"
aria-describedby="inputGroup-sizing-default"
type="number"
id="oneTimeInvestment"
v-model="oneTimeInvestment"
step="1000"
placeholder="one time investment"
/>
</div>
<div class="input-group mb-3">
<input
class="form-control"
aria-describedby="inputGroup-sizing-default"
type="number"
id="years"
v-model="years"
step="1"
placeholder="years"
/>
</div>
<button class="btn btn-block" @click="calculate" data-test="create-offer">
Make me an offer
</button>
</div>
</template>
<script>
export default {
data() {
return {
selectedFundId: "",
oneTimeInvestment: null,
years: null,
products: [
{ id: 1, name: "Galleon Guardian Fund", risk: "LOW", interestRate: 4 },
{
id: 2,
name: "Phoenix Feather Portfolio",
risk: "MEDIUM",
interestRate: 6,
},
{ id: 3, name: "Basilisk Bonds", risk: "HIGH", interestRate: 9 },
{
id: 4,
name: "Polyjuice Potion Fund",
risk: "MEDIUM",
interestRate: 5,
},
{
id: 5,
name: "Marauder's Map Mutuals",
risk: "HIGH",
interestRate: 8,
},
{
id: 6,
name: "Dementor Defense Dividends",
risk: "INSANE",
interestRate: 12,
},
{
id: 7,
name: "Quidditch Quest Equities",
risk: "MEDIUM",
interestRate: 6,
},
{
id: 8,
name: "Hogwarts Heritage Holdings",
risk: "HIGH",
interestRate: 9,
},
{
id: 9,
name: "Pensieve Prosperity Partners",
risk: "LOW",
interestRate: 3,
},
{ id: 10, name: "Owl Post Options", risk: "MEDIUM", interestRate: 5 },
{
id: 11,
name: "Death Eater Dominance Fund",
risk: "INSANE",
interestRate: 15,
},
],
};
},
computed: {
selectedFund() {
return this.products.find((fund) => fund.id == this.selectedFundId);
},
},
methods: {
calculate() {
if (this.selectedFund && this.oneTimeInvestment && this.years) {
const oneTimeInvestment = parseFloat(this.oneTimeInvestment);
const interestRate = this.selectedFund.interestRate / 100;
const years = parseInt(this.years);
// Simple compound interest calculation
const totalInvestment =
oneTimeInvestment * Math.pow(1 + interestRate, years);
const interestIncome = totalInvestment - oneTimeInvestment;
const taxes = interestIncome * 0.19;
const netIncome = totalInvestment - taxes;
const calculatedData = {
oneTimeInvestment,
fund: this.selectedFund,
years,
totalInvestment,
interestIncome,
taxes,
netIncome,
};
this.$emit("calculation-done", calculatedData);
this.oneTimeInvestment = null;
this.selectedFundId = "";
this.years = null;
}
},
},
};
</script>
<style scoped>
/* Add your component-specific styles here */
label {
display: block;
margin-top: 10px;
}
input,
select,
button {
margin-top: 0px;
border-radius: 0;
}
button {
background-color: #747264;
color: #fff;
font-weight: 700;
padding: 10px;
font-size: 1.2em;
}
h2 {
color: #747264;
text-align: left;
}
</style>
@@ -0,0 +1,123 @@
<template>
<div class="mt-5 offer-detail">
<h2>The offer</h2>
<hr />
<div class="row">
<div class="col your-data">
<h4>Your data</h4>
<p>
Investment: <span>{{ formatAsMoneyGBP(newInvestment.oneTimeInvestment) }}</span>
</p>
<p class="period">Period: <span>{{ newInvestment.years }} years</span></p>
<p class="fund">Fund: <span>{{ newInvestment.fund.name }}</span></p>
<p class="risk">Risk: <span>{{ newInvestment.fund.risk }}</span></p>
</div>
<div class="col our-calculations">
<h4>Our calculations</h4>
<p>Net income: <span> {{ formatAsMoneyGBP(newInvestment.netIncome) }}</span></p>
<p>
Interest income: <span>{{ formatAsMoneyGBP(newInvestment.interestIncome) }}</span>
</p>
<p>
<span>
Total income:
{{
formatAsMoneyGBP(
newInvestment.netIncome + newInvestment.interestIncome
)
}}
</span>
</p>
</div>
</div>
<div class="input-group mb-3">
<input class="form-control" aria-describedby="inputGroup-sizing-default" type="text" id="customerName"
data-test="customer-name" v-model="customerName" placeholder="enter your name" />
</div>
<button class="btn btn-success mx-3" @click="createInvestment" data-test="create-investment">
Create Investment
</button>
<button class="btn btn-danger mx-3" @click="rejectOffer" data-test="decline-investment">
Reject Offer
</button>
</div>
</template>
<script>
export default {
props: {
newInvestment: Object, // Pass the calculatedData as a prop
},
data() {
return {
customerName: "",
};
},
methods: {
formatAsMoneyGBP(number) {
// Use Intl.NumberFormat to format the number as GBP
const formatter = new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Return the formatted string
return formatter.format(number);
},
createInvestment() {
if (this.customerName) {
console.log("emitting");
this.$emit("create-investment", this.customerName);
// Optionally, clear the input field after creating the investment
this.customerName = "";
}
},
rejectOffer() {
// Handle the rejection, e.g., emit an event or perform other actions
this.$emit("reject-offer");
},
},
};
</script>
<style scoped lang="scss">
div.offer-detail {
button {
font-weight: 700;
border-radius: 0;
}
input {
border-radius: 0;
}
hr {
background-color: #fff;
}
color: #fff;
background-color: #747264;
padding: 20px;
h4 {
text-align: left;
}
p {
text-align: left;
span {
font-weight: 800;
}
}
}
h2 {
text-align: left;
font-weight: 900;
}
</style>
@@ -0,0 +1,164 @@
<template>
<div>
<h2>Gringotts Investment List</h2>
<ul class="investment-list mt-3">
<li class="my-2" v-for="(investment, index) in investments" :key="index">
<h4>
Investment issued for <span>{{ investment.customerName }}</span>
<hr />
</h4>
<div class="row">
<div class="col">
<p>
Investment:
{{ formatAsMoneyGBP(investment.oneTimeInvestment) }}
</p>
<p>Period: {{ investment.years }} years</p>
<p>Fund: {{ investment.fund.name }}</p>
<p>Risk: {{ investment.fund.risk }}</p>
</div>
<div class="col">
<p>Net income: {{ formatAsMoneyGBP(investment.netIncome) }}</p>
<p>
Interest income:
{{ formatAsMoneyGBP(investment.interestIncome) }}
</p>
<p>
<span>
Total income:
{{
formatAsMoneyGBP(
investment.netIncome + investment.interestIncome
)
}}
</span>
</p>
</div>
</div>
<div class="row">
<div class="col">
<button
class="btn btn-primary mr-2 float-left"
id="showDetail"
@click="openInvestmentModal(index)"
>
View Details
</button>
<button
class="btn btn-danger float-left"
id="delete"
@click="deleteInvestment(index)"
>
Delete
</button>
</div>
</div>
</li>
</ul>
<GringottsInvestmentModal
v-if="selectedInvestment !== null"
:investment="selectedInvestment"
:is-open="isModalOpen"
@close-investment-modal="closeInvestmentModal"
/>
</div>
</template>
<script>
import GringottsInvestmentModal from "../gringotts-bank/GringottsInvestmentModal.vue";
export default {
props: {
investments: Array, // Array of investments passed as a prop
},
components: {
GringottsInvestmentModal,
},
data() {
return {
selectedInvestment: null,
isModalOpen: false,
};
},
methods: {
formatAsMoneyGBP(number) {
// Use Intl.NumberFormat to format the number as GBP
const formatter = new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Return the formatted string
return formatter.format(number);
},
deleteInvestment(index) {
// Trigger the deletion of the investment at the specified index
this.$emit("delete-investment", index);
},
openInvestmentModal(index) {
this.selectedInvestment = this.investments[index];
this.isModalOpen = true;
},
closeInvestmentModal() {
this.selectedInvestment = null;
this.isModalOpen = false;
},
},
};
</script>
<style scoped lang="scss">
h2 {
color: #747264;
text-align: left;
}
ul.investment-list {
list-style: none;
padding: 0;
li {
hr {
background-color: #fff;
}
h4 {
text-align: left;
span {
font-weight: 900;
}
}
color: #fff;
background-color: #747264;
padding: 20px;
button {
border-radius: 0;
}
button#showDetail {
background-color: #504e44;
border: none;
&:hover {
background-color: #676458;
cursor: pointer;
}
}
p {
text-align: left;
span {
font-weight: 800;
}
}
h5 {
text-align: left;
}
transition: all 200ms ease-in;
&:hover {
background-color: #817f71;
cursor: pointer;
}
}
}
</style>
@@ -0,0 +1,117 @@
<!-- GringottsInvestmentModal.vue -->
<template>
<div v-if="isOpen" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h2>Investment Details</h2>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<p>Fund: {{ investment.fund.name }}</p>
<p>Risk: {{ investment.fund.risk }}</p>
<p>Name: {{ investment.customerName }}</p>
<p>
Investment: {{ formatAsMoneyGBP(investment.oneTimeInvestment) }}
</p>
<p>Years: {{ investment.years }}</p>
</div>
<div class="col">
<p>Net income: {{ formatAsMoneyGBP(investment.netIncome) }}</p>
<p>
Interest income:
{{ formatAsMoneyGBP(investment.interestIncome) }}
</p>
<p>
Total income:
{{
formatAsMoneyGBP(
investment.netIncome + investment.interestIncome
)
}}
</p>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-dismiss="modal"
@click="closeInvestmentModal"
id="close"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
investment: Object, // The specific investment object to display details
isOpen: Boolean, // Whether the modal is open or not
},
methods: {
closeInvestmentModal() {
this.$emit("close-investment-modal"); // Emit an event to close the modal
},
formatAsMoneyGBP(number) {
// Use Intl.NumberFormat to format the number as GBP
const formatter = new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Return the formatted string
return formatter.format(number);
},
},
};
</script>
<style scoped lang="scss">
/* Add your modal styles here */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
.modal-dialog {
margin: 0 auto;
width: 1200px;
.modal-content {
color: #fff;
padding: 20px;
border-radius: 0;
background-color: #747264;
border: none;
p {
text-align: left;
}
button#close {
background-color: #504e44;
border: none;
border-radius: 0;
&:hover {
background-color: #676458;
cursor: pointer;
}
}
}
}
}
</style>
@@ -0,0 +1,10 @@
export default {
name: "Modal",
props: ["spell"],
methods: {
close() {
this.$emit("close");
}
}
};
+31
View File
@@ -0,0 +1,31 @@
import Vue from "vue";
import App from "./App.vue";
import axios from "axios";
import VueRouter from "vue-router";
import Spelleology from "../src/components/Spelleology";
import SortingHat from "../src/components/SortingHat";
import Welcome from "../src/components/Welcome";
import QuoteGenerator from "../src/components/QuoteGenerator";
import GringottsBank from "../src/components/GringottsBank";
const axiosconfig = {
baseURL: process.env.VUE_APP_API_URL || "http://localhost:3000",
timeout: 30000,
};
Vue.prototype.$http = axios.create(axiosconfig);
Vue.use(VueRouter);
const router = new VueRouter({
routes: [
{ path: "/spelleology", component: Spelleology },
{ path: "/sortingHat", component: SortingHat },
{ path: "/quotes", component: QuoteGenerator },
{ path: "/gringottsBank", component: GringottsBank },
{ path: "/", component: Welcome },
],
});
new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
@@ -0,0 +1,8 @@
module.exports = {
//...
resolve: {
fallback: {
url: require.resolve("url"),
},
},
};