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
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
jobs:
test:
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
Expand Down
34 changes: 34 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,28 @@ declare module '@vue/runtime-core' {
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default']
'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
IconMdiCamera: typeof import('~icons/mdi/camera')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default']
IconMdiPause: typeof import('~icons/mdi/pause')['default']
IconMdiPlay: typeof import('~icons/mdi/play')['default']
IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiTranslate: typeof import('~icons/mdi/translate')['default']
IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
Expand Down Expand Up @@ -129,20 +140,42 @@ declare module '@vue/runtime-core' {
MenuLayout: typeof import('./src/components/MenuLayout.vue')['default']
MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default']
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCode: typeof import('naive-ui')['NCode']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDivider: typeof import('naive-ui')['NDivider']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable']
NTag: typeof import('naive-ui')['NTag']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']
Expand Down Expand Up @@ -173,6 +206,7 @@ declare module '@vue/runtime-core' {
TextToBinary: typeof import('./src/tools/text-to-binary/text-to-binary.vue')['default']
TextToNatoAlphabet: typeof import('./src/tools/text-to-nato-alphabet/text-to-nato-alphabet.vue')['default']
TextToUnicode: typeof import('./src/tools/text-to-unicode/text-to-unicode.vue')['default']
TipCalculator: typeof import('./src/tools/tip-calculator/tip-calculator.vue')['default']
TokenDisplay: typeof import('./src/tools/otp-code-generator-and-validator/token-display.vue')['default']
'TokenGenerator.tool': typeof import('./src/tools/token-generator/token-generator.tool.vue')['default']
TomlToJson: typeof import('./src/tools/toml-to-json/toml-to-json.vue')['default']
Expand Down
4 changes: 4 additions & 0 deletions locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ tools:
title: Percentage calculator
description: Easily calculate percentages from a value to another value, or from a percentage to a value.

tip-calculator:
title: Tip calculator
description: Calculate the tip for a bill and split it among multiple people.

svg-placeholder-generator:
title: SVG placeholder generator
description: Generate svg images to use as a placeholder in your applications.
Expand Down
2 changes: 1 addition & 1 deletion src/modules/command-palette/command-palette.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ function activateOption(option: PaletteOption) {
<c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable />

<div v-for="(options, category) in filteredSearchResult" :key="category">
<div ml-3 mt-3 text-sm font-bold text-primary op-60>
<div ml-3 mt-3 text-sm text-primary font-bold op-60>
{{ category }}
</div>
<command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" />
Expand Down
3 changes: 2 additions & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { tool as dateTimeConverter } from './date-time-converter';
import { tool as deviceInformation } from './device-information';
import { tool as cypher } from './encryption';
import { tool as etaCalculator } from './eta-calculator';
import { tool as tipCalculator } from './tip-calculator';
import { tool as percentageCalculator } from './percentage-calculator';
import { tool as gitMemo } from './git-memo';
import { tool as hashText } from './hash-text';
Expand Down Expand Up @@ -168,7 +169,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Math',
components: [mathEvaluator, etaCalculator, percentageCalculator],
components: [mathEvaluator, etaCalculator, percentageCalculator, tipCalculator],
},
{
name: 'Measurement',
Expand Down
13 changes: 13 additions & 0 deletions src/tools/tip-calculator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Calculator } from '@vicons/tabler';
import { defineTool } from '../tool';
import { translate } from '@/plugins/i18n.plugin';

export const tool = defineTool({
name: translate('tools.tip-calculator.title'),
path: '/tip-calculator',
description: translate('tools.tip-calculator.description'),
keywords: ['tip', 'calculator', 'bill', 'split', 'restaurant', 'money', 'payment'],
component: () => import('./tip-calculator.vue'),
icon: Calculator,
createdAt: new Date('2024-04-17'),
});
50 changes: 50 additions & 0 deletions src/tools/tip-calculator/tip-calculator.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';

test.describe('Tool - Tip calculator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/tip-calculator');
await page.locator('[data-test-id="billAmount"]').waitFor();
});

test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Tip calculator - IT Tools');
});

test('Correctly calculates tip and split', async ({ page }) => {
// Fill bill amount
await page.getByTestId('billAmount').locator('input').fill('100');

// Fill tip percentage
await page.getByTestId('tipPercentage').locator('input').fill('15');

// Fill number of people
await page.getByTestId('numberOfPeople').locator('input').fill('2');

// Check results
// 100 * 0.15 = 15.00
await expect(page.getByTestId('tipAmountResult').locator('input')).toHaveValue('15.00');

// 100 + 15 = 115.00
await expect(page.getByTestId('totalBillResult').locator('input')).toHaveValue('115.00');

// 115 / 2 = 57.50
await expect(page.getByTestId('amountPerPersonResult').locator('input')).toHaveValue('57.50');
});

test('Quick tip buttons work', async ({ page }) => {
await page.getByTestId('billAmount').locator('input').fill('100');

// Click 20% button
await page.getByRole('button', { name: '20%' }).click();

await expect(page.getByTestId('tipPercentage').locator('input')).toHaveValue('20');
await expect(page.getByTestId('tipAmountResult').locator('input')).toHaveValue('20.00');
});

test('Displays initial/empty results correctly', async ({ page }) => {
// Initial state with empty bill
await expect(page.getByTestId('tipAmountResult').locator('input')).toHaveValue('0.00');
await expect(page.getByTestId('totalBillResult').locator('input')).toHaveValue('0.00');
await expect(page.getByTestId('amountPerPersonResult').locator('input')).toHaveValue('0.00');
});
});
84 changes: 84 additions & 0 deletions src/tools/tip-calculator/tip-calculator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script setup lang="ts">
const billAmount = ref<number>();
const tipPercentage = ref<number>(15);
const numberOfPeople = ref<number>(1);

const tipAmount = computed(() => (billAmount.value && tipPercentage.value) ? (billAmount.value * tipPercentage.value) / 100 : 0);
const totalAmount = computed(() => billAmount.value ? billAmount.value + tipAmount.value : 0);
const amountPerPerson = computed(() => (totalAmount.value && numberOfPeople.value && numberOfPeople.value > 0) ? totalAmount.value / numberOfPeople.value : 0);

const formatNum = (v: number) => new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v);

const results = computed(() => [
{ label: 'Tip Amount', val: formatNum(tipAmount.value), id: 'tipAmountResult' },
{ label: 'Total Bill', val: formatNum(totalAmount.value), id: 'totalBillResult' },
{ label: 'Amount Per Person', val: formatNum(amountPerPerson.value), id: 'amountPerPersonResult', isBold: true },
]);
</script>

<template>
<div style="flex: 0 0 100%">
<div style="margin: 0 auto; max-width: 600px">
<c-card mb-3>
<div mb-3>
Bill details
</div>
<div flex flex-col gap-4>
<div flex items-center gap-2>
<div style="min-width: 120px;">
Bill Amount
</div>
<div style="flex: 1" data-test-id="billAmount">
<n-input-number v-model:value="billAmount" :min="0" placeholder="Total Bill" />
</div>
</div>
<div flex items-center gap-2>
<div style="min-width: 120px;">
Tip Percentage
</div>
<div style="flex: 1" data-test-id="tipPercentage">
<n-input-number v-model:value="tipPercentage" :min="0" placeholder="Tip %">
<template #suffix>
%
</template>
</n-input-number>
</div>
</div>
<div flex items-center gap-2>
<div style="min-width: 120px;">
Number of People
</div>
<div style="flex: 1" data-test-id="numberOfPeople">
<n-input-number v-model:value="numberOfPeople" :min="1" placeholder="People" />
</div>
</div>
</div>
</c-card>

<c-card mb-3>
<div mb-3>
Results
</div>
<div flex flex-col gap-3>
<div v-for="res in results" :key="res.id" flex items-center justify-between :class="{ 'border-t pt-3 font-bold': res.isBold }">
<span>{{ res.label }}:</span>
<div :data-test-id="res.id" style="max-width: 200px; width: 100%;">
<input-copyable :value="res.val" readonly />
</div>
</div>
</div>
</c-card>

<c-card>
<div mb-2>
Quick Tip %
</div>
<div flex gap-2>
<n-button v-for="tip in [10, 15, 18, 20, 25]" :key="tip" size="small" @click="tipPercentage = tip">
{{ tip }}%
</n-button>
</div>
</c-card>
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion src/ui/c-select/c-select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function onSearchInput() {
>
<div flex-1 truncate>
<slot name="displayed-value">
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full color-current lh-normal @input="onSearchInput">
<span v-else-if="selectedOption" lh-normal>
{{ selectedOption.label }}
</span>
Expand Down
2 changes: 1 addition & 1 deletion src/ui/c-table/c-table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const headers = computed(() => {
<template>
<div class="relative overflow-x-auto rounded">
<table class="w-full border-collapse text-left text-sm text-gray-500 dark:text-gray-400" role="table" :aria-label="description">
<thead v-if="!hideHeaders" class="bg-#ffffff uppercase text-gray-700 dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5">
<thead v-if="!hideHeaders" class="bg-#ffffff text-gray-700 uppercase dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5">
<tr>
<th v-for="header in headers" :key="header.key" scope="col" class="px-6 py-3 text-xs">
{{ header.label }}
Expand Down
Loading