feat: add at a glance dashboard

fix: accept both api_key and apiKey as parameter on the api
feat: allow to disable password login when oidc is enabled
feat: add get_oidc_settings endpoint to the api
feat: refactor css colors
feat: ai recommendations with chatgpt, gemini or ollama
feat: display ai recommendations on the dashboard
This commit is contained in:
Miguel Ribeiro
2025-08-12 00:48:13 +02:00
committed by GitHub
parent f51420799d
commit ba6dddf526
65 changed files with 4279 additions and 1989 deletions

View File

@@ -30,7 +30,8 @@ if ($oidcSettings === false) {
'user_identifier_field' => 'sub',
'scopes' => 'openid email profile',
'auth_style' => 'auto',
'auto_create_user' => 0
'auto_create_user' => 0,
'password_login_disabled' => 0
];
}
@@ -257,6 +258,12 @@ $loginDisabledAllowed = $userCount == 1 && $settings['registrations_open'] == 0;
<input type="checkbox" id="oidcAutoCreateUser" <?= $oidcSettings['auto_create_user'] ? 'checked' : '' ?> />
<label for="oidcAutoCreateUser"><?= translate('create_user_automatically', $i18n) ?></label>
</div>
<div class="form-group-inline">
<input type="checkbox" id="oidcPasswordLoginDisabled"
<?= $oidcSettings['password_login_disabled'] ? 'checked' : '' ?>
<?= $loginDisabledAllowed ? '' : 'disabled' ?> />
<label for="oidcPasswordLoginDisabled"><?= translate('disable_password_login', $i18n) ?></label>
</div>
<div class="buttons">
<input type="submit" class="thin mobile-grow" value="<?= translate('save', $i18n) ?>"
id="saveOidcSettingsButton" onClick="saveOidcSettingsButton()" />

View File

@@ -40,7 +40,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -49,7 +51,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -0,0 +1,114 @@
<?php
/*
This API Endpoint accepts both POST and GET requests.
It receives the following parameters:
- api_key: the API key of the user.
It returns a JSON object with the following properties:
- success: whether the request was successful (boolean).
- title: the title of the response (string).
- oidc_settings: an object containing the OIDC settings.
- notes: warning messages or additional information (array).
Example response:
{
"success": true,
"title": "oidc_settings",
"oidc_settings": {
"name": "Authentik",
"client_id": "CJMLcyyS94cUMXkitNZuokayArnn23TXxpeUv48E",
"client_secret": "SzfQBIibfN0gEAgCORrKnGnrYe9yqASWAYUuu1byelVosCHlnoqAdWlMDppblyuByb38Zw78AAlgMmdK6SWpGjOU4IiqaoltkAEh52trcqCB8briP1TqqXZdar4xfhVw",
"authorization_url": "https://auth.bellamylab.com/application/o/authorize/",
"token_url": "https://auth.bellamylab.com/application/o/token/",
"user_info_url": "https://auth.bellamylab.com/application/o/userinfo/",
"redirect_url": "http://localhost:80/wallos",
"logout_url": "https://auth.bellamylab.com/application/o/wallos/end-session/",
"user_identifier_field": "sub",
"scopes": "openid email profile",
"auth_style": "auto",
"created_at": "2025-07-20 20:31:50",
"updated_at": "2025-07-20 20:31:50",
"auto_create_user": 0,
"password_login_disabled": 0
},
"notes": []
}
*/
require_once '../../includes/connect_endpoint.php';
header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
];
echo json_encode($response);
exit;
}
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";
$stmt = $db->prepare($sql);
$stmt->bindValue(':apiKey', $apiKey);
$result = $stmt->execute();
$user = $result->fetchArray(SQLITE3_ASSOC);
// If the user is not found, return an error
if (!$user) {
$response = [
"success" => false,
"title" => "Invalid API key"
];
echo json_encode($response);
exit;
}
$userId = $user['id'];
if ($userId !== 1) {
$response = [
"success" => false,
"title" => "Invalid user"
];
echo json_encode($response);
exit;
}
$sql = "SELECT * FROM 'oauth_settings' WHERE id = 1";
$stmt = $db->prepare($sql);
$result = $stmt->execute();
$oidc_settings = $result->fetchArray(SQLITE3_ASSOC);
if ($oidc_settings) {
unset($oidc_settings['id']);
}
$response = [
"success" => true,
"title" => "oidc_settings",
"oidc_settings" => $oidc_settings,
"notes" => []
];
echo json_encode($response);
$db->close();
} else {
$response = [
"success" => false,
"title" => "Invalid request method"
];
echo json_encode($response);
exit;
}
?>

View File

@@ -0,0 +1,101 @@
<?php
/*
This API Endpoint accepts POST requests only.
It receives the following parameters:
- api_key: the API key of the user.
- disable: '1' to disable password login, '0' to enable it.
It returns a JSON object with the following properties:
- success: whether the request was successful (boolean).
- title: the title of the response (string).
- message: detailed information or error message (string).
Example response:
{
"success": true,
"title": "Updated",
"message": "Password login has been disabled."
}
*/
require_once '../../includes/connect_endpoint.php';
header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode([
'success' => false,
'title' => 'Invalid request method',
'message' => 'Only POST requests are allowed.'
]);
exit;
}
$apiKey = $_POST['api_key'] ?? null;
// Authenticate user first
if (!$apiKey) {
echo json_encode([
'success' => false,
'title' => 'Missing API key',
'message' => 'API key is required.'
]);
exit;
}
$sql = "SELECT * FROM user WHERE api_key = :apiKey";
$stmt = $db->prepare($sql);
$stmt->bindValue(':apiKey', $apiKey);
$result = $stmt->execute();
$user = $result->fetchArray(SQLITE3_ASSOC);
if (!$user || $user['id'] !== 1) {
echo json_encode([
'success' => false,
'title' => 'Unauthorized',
'message' => 'Invalid API key or insufficient privileges.'
]);
exit;
}
// Now check 'disable' parameter only after authentication
$disable = $_POST['disable'] ?? null;
if (!isset($disable)) {
echo json_encode([
'success' => false,
'title' => 'Missing parameter',
'message' => 'Parameter "disable" is required.'
]);
exit;
}
if (!in_array($disable, ['0', '1'], true)) {
echo json_encode([
'success' => false,
'title' => 'Invalid parameter',
'message' => 'Parameter "disable" must be "0" or "1".'
]);
exit;
}
// Update the password_login_disabled setting
$updateSql = "UPDATE oauth_settings SET password_login_disabled = :disable WHERE id = 1";
$updateStmt = $db->prepare($updateSql);
$updateStmt->bindValue(':disable', intval($disable), SQLITE3_INTEGER);
$updateResult = $updateStmt->execute();
if ($updateResult) {
echo json_encode([
'success' => true,
'title' => 'Updated',
'message' => "Password login has been " . ($disable === '1' ? "disabled" : "enabled") . "."
]);
} else {
echo json_encode([
'success' => false,
'title' => 'Database error',
'message' => 'Failed to update the setting.'
]);
}
$db->close();

View File

@@ -45,7 +45,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -54,7 +56,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -53,7 +53,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -62,7 +64,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -30,7 +30,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -39,7 +41,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -39,7 +39,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -48,7 +50,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -43,7 +43,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -52,7 +54,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -75,7 +75,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -84,7 +86,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -39,7 +39,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -48,7 +50,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -29,7 +29,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -38,7 +40,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -15,7 +15,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -24,6 +26,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
function getPriceConverted($price, $currency, $database)
{
$query = "SELECT rate FROM currencies WHERE id = :currency";
@@ -40,8 +43,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
}
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";
$stmt = $db->prepare($sql);

View File

@@ -34,7 +34,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['month']) || !isset($_REQUEST['year']) || !isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey || !isset($_REQUEST['month']) || !isset($_REQUEST['year'])) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -45,7 +47,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
$month = $_REQUEST['month'];
$year = $_REQUEST['year'];
$apiKey = $_REQUEST['api_key'];
$sql = "SELECT * FROM user WHERE api_key = :apiKey";
$stmt = $db->prepare($sql);

View File

@@ -87,7 +87,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -96,6 +98,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
function getPriceConverted($price, $currency, $database)
{
$query = "SELECT rate FROM currencies WHERE id = :currency";
@@ -112,8 +115,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
}
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";
$stmt = $db->prepare($sql);

View File

@@ -37,7 +37,9 @@ header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error
if (!isset($_REQUEST['api_key'])) {
$apiKey = $_REQUEST['api_key'] ?? $_REQUEST['apiKey'] ?? null;
if (!$apiKey) {
$response = [
"success" => false,
"title" => "Missing parameters"
@@ -46,7 +48,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET
exit;
}
$apiKey = $_REQUEST['api_key'];
// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";

View File

@@ -34,6 +34,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$oidcScopes = isset($data['oidcScopes']) ? trim($data['oidcScopes']) : '';
$oidcAuthStyle = isset($data['oidcAuthStyle']) ? trim($data['oidcAuthStyle']) : '';
$oidcAutoCreateUser = isset($data['oidcAutoCreateUser']) ? (int)$data['oidcAutoCreateUser'] : 0;
$oidcPasswordLoginDisabled = isset($data['oidcPasswordLoginDisabled']) ? (int)$data['oidcPasswordLoginDisabled'] : 0;
$checkStmt = $db->prepare('SELECT COUNT(*) as count FROM oauth_settings WHERE id = 1');
$result = $checkStmt->execute();
@@ -53,14 +54,15 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
user_identifier_field = :oidcUserIdentifierField,
scopes = :oidcScopes,
auth_style = :oidcAuthStyle,
auto_create_user = :oidcAutoCreateUser
auto_create_user = :oidcAutoCreateUser,
password_login_disabled = :oidcPasswordLoginDisabled
WHERE id = 1');
} else {
// Insert new row
$stmt = $db->prepare('INSERT INTO oauth_settings (
id, name, client_id, client_secret, authorization_url, token_url, user_info_url, redirect_url, logout_url, user_identifier_field, scopes, auth_style, auto_create_user
id, name, client_id, client_secret, authorization_url, token_url, user_info_url, redirect_url, logout_url, user_identifier_field, scopes, auth_style, auto_create_user, password_login_disabled
) VALUES (
1, :oidcName, :oidcClientId, :oidcClientSecret, :oidcAuthUrl, :oidcTokenUrl, :oidcUserInfoUrl, :oidcRedirectUrl, :oidcLogoutUrl, :oidcUserIdentifierField, :oidcScopes, :oidcAuthStyle, :oidcAutoCreateUser
1, :oidcName, :oidcClientId, :oidcClientSecret, :oidcAuthUrl, :oidcTokenUrl, :oidcUserInfoUrl, :oidcRedirectUrl, :oidcLogoutUrl, :oidcUserIdentifierField, :oidcScopes, :oidcAuthStyle, :oidcAutoCreateUser, :oidcPasswordLoginDisabled
)');
}
@@ -76,6 +78,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$stmt->bindParam(':oidcScopes', $oidcScopes, SQLITE3_TEXT);
$stmt->bindParam(':oidcAuthStyle', $oidcAuthStyle, SQLITE3_TEXT);
$stmt->bindParam(':oidcAutoCreateUser', $oidcAutoCreateUser, SQLITE3_INTEGER);
$stmt->bindParam(':oidcPasswordLoginDisabled', $oidcPasswordLoginDisabled, SQLITE3_INTEGER);
$stmt->execute();
if ($db->changes() > 0) {

View File

@@ -0,0 +1,143 @@
<?php
require_once '../../includes/connect_endpoint.php';
$chatgptModelsApiUrl = 'https://api.openai.com/v1/models';
$geminiModelsApiUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
if (isset($_SESSION['loggedin']) && $_SESSION['loggedin'] === true) {
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Check if ai-type and ai-api-key are set
$aiType = isset($data["type"]) ? trim($data["type"]) : '';
$aiApiKey = isset($data["api_key"]) ? trim($data["api_key"]) : '';
$aiOllamaHost = isset($data["ollama_host"]) ? trim($data["ollama_host"]) : '';
// Validate ai-type
if (!in_array($aiType, ['chatgpt', 'gemini', 'ollama'])) {
$response = [
"success" => false,
"message" => translate('error', $i18n)
];
echo json_encode($response);
exit;
}
// Validate ai-api-key and fetch models if ai-type is chatgpt or gemini
if ($aiType === 'chatgpt' || $aiType === 'gemini') {
if (empty($aiApiKey)) {
$response = [
"success" => false,
"message" => translate('invalid_api_key', $i18n)
];
echo json_encode($response);
exit;
}
}
// Prepare the request headers
$headers = [
'Content-Type: application/json',
];
if ($aiType === 'chatgpt') {
$headers[] = 'Authorization: Bearer ' . $aiApiKey;
$apiUrl = $chatgptModelsApiUrl;
} elseif ($aiType === 'gemini') {
$apiUrl = $geminiModelsApiUrl . '?key=' . urlencode($aiApiKey);
} else {
// For ollama, no API key is needed
// Check for ollama host
if (empty($aiOllamaHost)) {
$response = [
"success" => false,
"message" => translate('invalid_host', $i18n)
];
echo json_encode($response);
exit;
}
$apiUrl = $aiOllamaHost . '/api/tags';
}
// Initialize cURL
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // Set a timeout for the request
// Execute the request
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
$response = [
"success" => false,
"message" => ($aiType === 'ollama')
? translate('invalid_host', $i18n)
: translate('error', $i18n)
];
} else {
// Decode the response
$modelsData = json_decode($response, true);
if ($aiType === 'gemini' && isset($modelsData['models']) && is_array($modelsData['models'])) {
// Normalize Gemini response
$models = array_map(function ($model) {
return [
'id' => str_replace('models/', '', $model['name']),
'name' => $model['displayName'] ?? $model['name'],
];
}, $modelsData['models']);
$response = [
"success" => true,
"models" => $models
];
} elseif (isset($modelsData['data']) && is_array($modelsData['data'])) {
// OpenAI format
$models = array_map(function ($model) {
return [
'id' => $model['id'],
'name' => $model['name'] ?? $model['id'],
];
}, $modelsData['data']);
$response = [
"success" => true,
"models" => $models
];
} elseif ($aiType === 'ollama' && isset($modelsData['models']) && is_array($modelsData['models'])) {
// Normalize Ollama response
$models = array_map(function ($model) {
return [
'id' => $model['name'],
'name' => $model['name'],
];
}, $modelsData['models']);
$response = [
"success" => true,
"models" => $models
];
} else {
$response = [
"success" => false,
"message" => ($aiType === 'ollama')
? translate('invalid_host', $i18n)
: translate('invalid_api_key', $i18n)
];
}
}
// Close cURL session
curl_close($ch);
// Return the response as JSON
echo json_encode($response);
} else {
$response = [
"success" => false,
"message" => translate('invalid_request_method', $i18n)
];
echo json_encode($response);
}
} else {
$response = [
"success" => false,
"message" => translate('session_expired', $i18n)
];
echo json_encode($response);
}

View File

@@ -0,0 +1,326 @@
<?php
set_time_limit(300);
require_once '../../includes/connect_endpoint.php';
function getPricePerMonth($cycle, $frequency, $price)
{
switch ($cycle) {
case 1:
return $price * (30 / $frequency); // daily
case 2:
return $price * (4.35 / $frequency); // weekly
case 3:
return $price / $frequency; // monthly
case 4:
return $price / (12 * $frequency); // yearly
default:
return $price;
}
}
function describeFrequency($cycle, $frequency)
{
$unit = match ($cycle) {
1 => 'day',
2 => 'week',
3 => 'month',
4 => 'year',
default => 'unit'
};
if ($frequency == 1) {
return "Every $unit";
} else {
return "Every $frequency {$unit}s";
}
}
function describeCurrency($currencyId, $currencies)
{
return $currencies[$currencyId]['code'] ?? '';
}
if (isset($_SESSION['loggedin']) && $_SESSION['loggedin'] === true) {
// Get AI settings for the user from the database
$stmt = $db->prepare("SELECT * FROM ai_settings WHERE user_id = ?");
$stmt->bindValue(1, $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$aiSettings = $result->fetchArray(SQLITE3_ASSOC);
$stmt->close();
if (!$aiSettings) {
$response = [
"success" => false,
"message" => translate('error', $i18n)
];
echo json_encode($response);
exit;
}
$type = isset($aiSettings['type']) ? $aiSettings['type'] : '';
$enabled = isset($aiSettings['enabled']) ? (bool) $aiSettings['enabled'] : false;
$model = isset($aiSettings['model']) ? $aiSettings['model'] : '';
$host = "";
$apiKey = "";
if (!in_array($type, ['chatgpt', 'gemini', 'ollama']) || !$enabled || empty($model)) {
$response = [
"success" => false,
"message" => translate('error', $i18n)
];
echo json_encode($response);
exit;
}
if ($type == 'ollama') {
$host = isset($aiSettings['url']) ? $aiSettings['url'] : '';
if (empty($host)) {
$response = [
"success" => false,
"message" => translate('invalid_host', $i18n)
];
echo json_encode($response);
exit;
}
} else {
$apiKey = isset($aiSettings['api_key']) ? $aiSettings['api_key'] : '';
if (empty($apiKey)) {
$response = [
"success" => false,
"message" => translate('invalid_api_key', $i18n)
];
echo json_encode($response);
exit;
}
}
// We have everything we need, fetch information from the dabase to send to the AI API
// Get the categories from the database for user with ID 1
$stmt = $db->prepare("SELECT * FROM categories WHERE user_id = :user_id");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$categories = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$categories[$row['id']] = $row;
}
// Get the currencies from the database for user with ID 1
$stmt = $db->prepare("SELECT * FROM currencies WHERE user_id = :user_id");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$currencies = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$currencies[$row['id']] = $row;
}
// Get houswhold members from the database for user with ID 1
$stmt = $db->prepare("SELECT * FROM household WHERE user_id = :user_id");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$members = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$members[$row['id']] = $row;
}
// Get language from the user table
$stmt = $db->prepare("SELECT language FROM user WHERE id = :user_id");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$userLanguage = $result->fetchArray(SQLITE3_ASSOC)['language'] ?? 'en';
// Get name from includes/i18n/languages.php
require_once '../../includes/i18n/languages.php';
$userLanguageName = $languages[$userLanguage]['name'] ?? 'English';
// Get subscriptions from the database for user with ID 1
$stmt = $db->prepare("SELECT * FROM subscriptions WHERE user_id = :user_id AND inactive = 0");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$subscriptions = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$subscriptions[] = $row;
}
if (!empty($subscriptions)) {
$subscriptionsForAI = [];
foreach ($subscriptions as $row) {
if ($row['inactive'])
continue;
$price = round($row['price'], 2);
$currencyCode = $currencies[$row['currency_id']]['code'] ?? '';
$priceFormatted = $currencyCode ? "$price $currencyCode" : "$price";
$payerName = $members[$row['payer_user_id']]['name'] ?? 'Unknown';
$subscriptionsForAI[] = [
'name' => $row['name'],
'price' => $priceFormatted,
'frequency' => describeFrequency($row['cycle'], $row['frequency']),
'category' => $categories[$row['category_id']]['name'] ?? 'Uncategorized',
'payer' => $payerName
];
}
// encode
$aiDataJson = json_encode($subscriptionsForAI, JSON_PRETTY_PRINT);
} else {
$response = [
"success" => false,
"message" => translate('error', $i18n)
];
echo json_encode($response);
exit;
}
$prompt = <<<PROMPT
You are a helpful assistant designed to help users save money on digital subscriptions.
The user has shared a list of their active subscriptions across household members. For each subscription, you are given:
- Name of the service
- Price (in original currency)
- Payment frequency (e.g., every month, every year, etc.)
- Category
- Payer (which household member pays for it)
Analyze the data and give 3 to 7 smart and specific recommendations to reduce subscription costs. If possible, include estimated savings for each suggestion.
Follow these guidelines:
- Do NOT suggest switching to family or group plans unless two or more different household members are paying for the same or similar service.
- Recognize known feature overlaps, such as:
• YouTube Premium includes YouTube Music.
• Amazon Prime includes Prime Video.
• Google One, iCloud+, and Proton all offer cloud storage.
• Real Debrid, All Debrid, and Premiumize offer similar download capabilities.
- Suggest rotating or cancelling subscriptions that serve similar purposes (e.g. multiple streaming or IPTV services).
- Recommend switching from monthly to yearly plans only if it provides clear savings and the user is likely to keep the service long-term.
- Suggest looking for promo or new customer deals if a service appears overpriced.
- Only recommend cancelling rarely used services if they do not provide unique value.
Return the result as a JSON array. Each item in the array should have:
- "title": a short summary of the suggestion
- "description": a longer explanation with reasoning
- "savings": a rough estimate like "10 EUR/month" or "60 EUR/year" (if possible)
If possible, all text should be in the user's language: {$userLanguageName}. Otherwise, use English.
Do not include any other text, just the JSON output. Absolutely no additional comments or explanations.
Here is the users data:
PROMPT;
$prompt .= "\n\n" . json_encode($subscriptionsForAI, JSON_PRETTY_PRINT);
// Prepare the cURL request
$ch = curl_init();
if ($type === 'ollama') {
curl_setopt($ch, CURLOPT_URL, $host . '/api/generate');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['model' => $model, 'prompt' => $prompt, 'stream' => false]));
} else {
$headers = ['Content-Type: application/json'];
if ($type === 'chatgpt') {
$headers[] = 'Authorization: Bearer ' . $apiKey;
curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'model' => $model,
'messages' => [['role' => 'user', 'content' => $prompt]]
]));
} elseif ($type === 'gemini') {
curl_setopt(
$ch,
CURLOPT_URL,
'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model) .
':generateContent?key=' . urlencode($apiKey)
);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'contents' => [
[
'parts' => [['text' => $prompt]]
]
]
]));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
// Execute the cURL request
$reply = curl_exec($ch);
// Check for errors
if (curl_errno($ch)) {
$response = [
"success" => false,
"message" => curl_error($ch)
];
echo json_encode($response);
exit;
}
// Close the cURL session
curl_close($ch);
// Try to decode the AI's JSON reply
$replyData = json_decode($reply, true); // decode into array
if ($type === 'chatgpt' && isset($replyData['choices'][0]['message']['content'])) {
$recommendationsJson = $replyData['choices'][0]['message']['content'];
$recommendations = json_decode($recommendationsJson, true);
} elseif ($type === 'gemini' && isset($replyData['candidates'][0]['content']['parts'][0]['text'])) {
$recommendationsJson = $replyData['candidates'][0]['content']['parts'][0]['text'];
$recommendations = json_decode($recommendationsJson, true);
} else {
$recommendations = json_decode($replyData['response'], true);
}
if (json_last_error() === JSON_ERROR_NONE && is_array($recommendations)) {
// Remove old recommendations for this user
$stmt = $db->prepare("DELETE FROM ai_recommendations WHERE user_id = :user_id");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$stmt->execute();
// Insert each new recommendation
$insert = $db->prepare("
INSERT INTO ai_recommendations (user_id, type, title, description, savings)
VALUES (:user_id, :type, :title, :description, :savings)
");
foreach ($recommendations as $rec) {
$insert->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$insert->bindValue(':type', 'subscription', SQLITE3_TEXT); // or any category you want
$insert->bindValue(':title', $rec['title'] ?? '', SQLITE3_TEXT);
$insert->bindValue(':description', $rec['description'] ?? '', SQLITE3_TEXT);
$insert->bindValue(':savings', $rec['savings'] ?? '', SQLITE3_TEXT);
$insert->execute();
}
$response = [
"success" => true,
"message" => translate('success', $i18n),
"recommendations" => $recommendations
];
echo json_encode($response);
exit;
} else {
$response = [
"success" => false,
"message" => translate('error', $i18n),
"json_error" => json_last_error_msg()
];
echo json_encode($response);
exit;
}
} else {
$response = [
"success" => false,
"message" => translate('session_expired', $i18n)
];
echo json_encode($response);
exit;
}

View File

@@ -0,0 +1,99 @@
<?php
require_once '../../includes/connect_endpoint.php';
if (isset($_SESSION['loggedin']) && $_SESSION['loggedin'] === true) {
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
$aiEnabled = isset($data['ai_enabled']) ? (bool) $data['ai_enabled'] : false;
$aiType = isset($data['ai_type']) ? trim($data['ai_type']) : '';
$aiApiKey = isset($data['api_key']) ? trim($data['api_key']) : '';
$aiOllamaHost = isset($data['ollama_host']) ? trim($data['ollama_host']) : '';
$aiModel = isset($data['model']) ? trim($data['model']) : '';
if (empty($aiType) || !in_array($aiType, ['chatgpt', 'gemini', 'ollama'])) {
$response = [
"success" => false,
"message" => translate('error', $i18n)
];
echo json_encode($response);
exit;
}
if (($aiType === 'chatgpt' || $aiType === 'gemini') && empty($aiApiKey)) {
$response = [
"success" => false,
"message" => translate('invalid_api_key', $i18n)
];
echo json_encode($response);
exit;
}
if ($aiType === 'ollama' && empty($aiOllamaHost)) {
$response = [
"success" => false,
"message" => translate('invalid_host', $i18n)
];
echo json_encode($response);
exit;
}
if (empty($aiModel)) {
$response = [
"success" => false,
"message" => translate('fill_mandatory_fields', $i18n)
];
echo json_encode($response);
exit;
}
if ($aiType === 'ollama') {
$aiApiKey = ''; // Ollama does not require an API key
} else {
$aiOllamaHost = ''; // Clear Ollama host if not using Ollama
}
// Remove existing AI settings for the user
$stmt = $db->prepare("DELETE FROM ai_settings WHERE user_id = ?");
$stmt->bindValue(1, $userId, SQLITE3_INTEGER);
$stmt->execute();
$stmt->close();
// Insert new AI settings
$stmt = $db->prepare("INSERT INTO ai_settings (user_id, type, enabled, api_key, model, url) VALUES (:user_id, :type, :enabled, :api_key, :model, :url)");
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
$stmt->bindValue(':type', $aiType, SQLITE3_TEXT);
$stmt->bindValue(':enabled', $aiEnabled, SQLITE3_INTEGER);
$stmt->bindValue(':api_key', $aiApiKey, SQLITE3_TEXT);
$stmt->bindValue(':model', $aiModel, SQLITE3_TEXT);
$stmt->bindValue(':url', $aiOllamaHost, SQLITE3_TEXT);
$result = $stmt->execute();
if ($result) {
$response = [
"success" => true,
"message" => translate('success', $i18n),
"enabled" => $aiEnabled
];
} else {
$response = [
"success" => false,
"message" => translate('error', $i18n)
];
}
echo json_encode($response);
} else {
$response = [
"success" => false,
"message" => translate('invalid_request_method', $i18n)
];
echo json_encode($response);
}
} else {
$response = [
"success" => false,
"message" => translate('session_expired', $i18n)
];
echo json_encode($response);
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -166,12 +166,12 @@ $mobileNavigation = $settings['mobile_nav'] ? "mobile-navigation" : "";
<span id="user" class="mobileNavigationHideOnMobile"><?= $userData['username'] ?></span>
</button>
<div class="dropdown-content">
<a href="profile.php" class="mobileNavigationHideOnMobile">
<?php include "images/siteicons/svg/mobile-menu/profile.php"; ?>
<?= translate('profile', $i18n) ?></a>
<a href="." class="mobileNavigationHideOnMobile">
<?php include "images/siteicons/svg/mobile-menu/home.php"; ?>
<?= translate('subscriptions', $i18n) ?></a>
<?= translate('dashboard', $i18n) ?></a>
<a href="subscriptions.php" class="mobileNavigationHideOnMobile">
<?php include "images/siteicons/svg/mobile-menu/subscriptions.php"; ?>
<?= translate('subscriptions', $i18n) ?></a>
<a href="calendar.php" class="mobileNavigationHideOnMobile">
<?php include "images/siteicons/svg/mobile-menu/calendar.php"; ?>
<?= translate('calendar', $i18n) ?></a>
@@ -181,6 +181,9 @@ $mobileNavigation = $settings['mobile_nav'] ? "mobile-navigation" : "";
<a href="settings.php" class="mobileNavigationHideOnMobile">
<?php include "images/siteicons/svg/mobile-menu/settings.php"; ?>
<?= translate('settings', $i18n) ?></a>
<a href="profile.php">
<?php include "images/siteicons/svg/mobile-menu/profile.php"; ?>
<?= translate('profile', $i18n) ?></a>
<?php if ($isAdmin): ?>
<a href="admin.php">
<?php include "images/siteicons/svg/mobile-menu/admin.php"; ?>
@@ -209,7 +212,8 @@ $mobileNavigation = $settings['mobile_nav'] ? "mobile-navigation" : "";
<?php
// find out which page is being viewed
$page = basename($_SERVER['PHP_SELF']);
$subscriptionsClass = $page === 'index.php' ? 'active' : '';
$dashboardClass = $page === 'index.php' ? 'active' : '';
$subscriptionsClass = $page === 'subscriptions.php' ? 'active' : '';
$calendarClass = $page === 'calendar.php' ? 'active' : '';
$statsClass = $page === 'stats.php' ? 'active' : '';
$settingsClass = $page === 'settings.php' ? 'active' : '';
@@ -220,8 +224,12 @@ $mobileNavigation = $settings['mobile_nav'] ? "mobile-navigation" : "";
if ($settings['mobile_nav'] == 1) {
?>
<nav class="mobile-nav">
<a href="." class="nav-link <?= $subscriptionsClass ?>" title="<?= translate('subscriptions', $i18n) ?>">
<a href="." class="nav-link <?= $dashboardClass ?>" title="<?= translate('dashboard', $i18n) ?>">
<?php include "images/siteicons/svg/mobile-menu/home.php"; ?>
<?= translate('dashboard', $i18n) ?>
</a>
<a href="subscriptions.php" class="nav-link <?= $subscriptionsClass ?>" title="<?= translate('subscriptions', $i18n) ?>">
<?php include "images/siteicons/svg/mobile-menu/subscriptions.php"; ?>
<?= translate('subscriptions', $i18n) ?>
</a>
<a href="calendar.php" class="nav-link <?= $calendarClass ?>" title="<?= translate('calendar', $i18n) ?>">
@@ -236,10 +244,6 @@ $mobileNavigation = $settings['mobile_nav'] ? "mobile-navigation" : "";
<?php include "images/siteicons/svg/mobile-menu/settings.php"; ?>
<?= translate('settings', $i18n) ?>
</a>
<a href="profile.php" class="nav-link <?= $profileClass ?>" title="<?= translate('profile', $i18n) ?>">
<?php include "images/siteicons/svg/mobile-menu/profile.php"; ?>
<?= translate('profile', $i18n) ?>
</a>
</nav>
<?php
}

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Úspěšné obnovení hesla",
// Header
"profile" => "Profil",
"dashboard" => "Přehled",
"subscriptions" => "Předplatná",
"stats" => "Statistiky",
"settings" => "Nastavení",
"admin" => "Administrace",
"about" => "O aplikaci",
"logout" => "Odhlásit se",
// Dashboard
"hello" => "Ahoj",
"upcoming_payments" => "Plánované platby",
"no_upcoming_payments" => "Žádné plánované platby",
"overdue_renewals" => "Zpožděná obnovení",
"ai_recommendations" => "Doporučení AI",
"your_budget" => "Váš rozpočet",
"budget" => "Rozpočet",
"budget_used" => "Využití rozpočtu",
"over_budget" => "Překročení rozpočtu",
"your_subscriptions" => "Vaše předplatná",
"your_savings" => "Vaše úspory",
// Subscriptions page
"subscription" => "Předplatné",
"no_subscriptions_yet" => "Zatím nemáte žádná předplatná",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "Získejte svůj klíč na",
"get_free_fixer_api_key" => "Získat bezplatný klíč API služby Fixer",
"get_key_alternative" => "Případně můžete získat bezplatný klíč API služby Fixer od",
"ai_model" => "AI Model",
"select_ai_model" => "Vybrat AI Model",
"run_schedule" => "Spustit plán",
"manually" => "Manuálně",
"coming_soon" => "Brzy bude k dispozici",
"invalid_host" => "Neplatný hostitel",
"ai_recommendations_info" => "AI Doporučení jsou generována na základě vašich předplatných a členů domácnosti.",
"may_take_time" => "V závislosti na poskytovateli, modelu a počtu předplatných může generování doporučení trvat nějakou dobu.",
"recommendations_visible_on_dashboard" => "Doporučení budou viditelná na řídicím panelu.",
"generate_recommendations" => "Generovat doporučení",
"display_settings" => "Nastavení zobrazení",
"theme_settings" => "Nastavení motivu",
"colors" => "Barvy",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "Nastavení OIDC",
"oidc_oauth_enabled" => "Povolit OIDC/OAuth",
"create_user_automatically" => "Automaticky vytvořit uživatele",
"disable_password_login" => "Zakázat přihlašování pomocí hesla",
"smtp_settings" => "Nastavení SMTP",
"smtp_usage_info" => "Bude použito pro obnovení hesla a další systémové e-maily.",
"maintenance_tasks" => "Úkoly údržby",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Adgangskoden blev nulstillet",
// Header
"profile" => "Profil",
"dashboard" => "Dashboard",
"subscriptions" => "Abonnementer",
"stats" => "Statistik",
"settings" => "Indstillinger",
"admin" => "Admin",
"about" => "Om",
"logout" => "Log ud",
// Dashboard
"hello" => "Hej",
"upcoming_payments" => "Kommende betalinger",
"no_upcoming_payments" => "Du har ingen kommende betalinger",
"overdue_renewals" => "Forsinkede fornyelser",
"ai_recommendations" => "Denne AI anbefaling",
"your_budget" => "Dit budget",
"budget" => "Budget",
"budget_used" => "Budget brugt",
"over_budget" => "Over budget",
"your_subscriptions" => "Dine abonnementer",
"your_savings" => "Dine besparelser",
// Subscriptions page
"subscription" => "Abonnement",
"no_subscriptions_yet" => "Du har endnu ingen abonnementer",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "Få din nøgle på",
"get_free_fixer_api_key" => "Få gratis Fixer API-nøgle",
"get_key_alternative" => "Alternativt kan du få en gratis fixer API-nøgle fra",
"ai_model" => "AI Model",
"select_ai_model" => "Vælg AI Model",
"run_schedule" => "Kør tidsplan",
"manually" => "Manuelt",
"coming_soon" => "Kommer snart",
"invalid_host" => "Ugyldig vært",
"ai_recommendations_info" => "AI anbefalinger genereres baseret på dine abonnementer og husstandsmedlemmer.",
"may_take_time" => "Afhængigt af udbyderen, modellen og antallet af abonnementer kan genereringen af anbefalinger tage noget tid.",
"recommendations_visible_on_dashboard" => "Anbefalinger vil være synlige på instrumentbrættet.",
"generate_recommendations" => "Generer anbefalinger",
"display_settings" => "Visningsindstillinger",
"theme_settings" => "Temaindstillinger",
"colors" => "Farver",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "OIDC-indstillinger",
"oidc_oauth_enabled" => "Aktivér OIDC/OAuth",
"create_user_automatically" => "Opret bruger automatisk",
"disable_password_login" => "Deaktivér adgangskode-login",
"smtp_settings" => "SMTP-indstillinger",
"smtp_usage_info" => "Vil blive brugt til adgangskodenulstilling og andre systemmails.",
// Maintenance Tasks

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Passwort erfolgreich zurückgesetzt",
// Header
"profile" => "Profil",
"dashboard" => "Dashboard",
"subscriptions" => "Abonnements",
"stats" => "Statistiken",
"settings" => "Einstellungen",
"admin" => "Admin",
"about" => "Über",
"logout" => "Logout",
// Dashboard
"hello" => "Hallo",
"upcoming_payments" => "Bevorstehende Zahlungen",
"no_upcoming_payments" => "Sie haben keine bevorstehenden Zahlungen",
"overdue_renewals" => "Überfällige Verlängerungen",
"ai_recommendations" => "AI Empfehlungen",
"your_budget" => "Ihr Budget",
"budget" => "Budget",
"budget_used" => "Budget verwendet",
"over_budget" => "Über Budget",
"your_subscriptions" => "Ihre Abonnements",
"your_savings" => "Ihre Ersparnisse",
// Subscriptions page
"subscription" => "Abonnement",
"no_subscriptions_yet" => "Keine Abonnements hinzugefügt",
@@ -216,7 +229,17 @@ $i18n = [
"get_key" => "Erhalte deinen key bei",
"get_free_fixer_api_key" => "Erhalte deinen kostenfreien Fixer API Key",
"get_key_alternative" => "Alternativ können Sie einen kostenlosen Fixer-Api-Schlüssel erhalten von",
"display_settings" => "Display-Einstellungen",
"ai_model" => "AI Modell",
"select_ai_model" => "Wählen Sie AI Modell",
"run_schedule" => "Zeitplan ausführen",
"manually" => "Manuell",
"coming_soon" => "Demnächst",
"invalid_host" => "Ungültiger Host",
"ai_recommendations_info" => "AI Empfehlungen werden basierend auf Ihren Abonnements und Haushaltsmitgliedern generiert.",
"may_take_time" => "Je nach Anbieter, Modell und Anzahl der Abonnements kann die Generierung von Empfehlungen einige Zeit in Anspruch nehmen.",
"recommendations_visible_on_dashboard" => "Empfehlungen werden auf dem Dashboard sichtbar sein.",
"generate_recommendations" => "Empfehlungen generieren",
"display_settings" => "Anzeigeeinstellungen",
"theme_settings" => "Themen-Einstellungen",
"colors" => "Farben",
"custom_colors" => "Benutzerdefinierte Farben",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "OIDC Einstellungen",
"oidc_oauth_enabled" => "OIDC/OAuth aktivieren",
"create_user_automatically" => "Benutzer automatisch erstellen",
"disable_password_login" => "Passwort-Login deaktivieren",
"smtp_settings" => "SMTP Einstellungen",
"smtp_usage_info" => "Wird für die Passwortwiederherstellung und andere System-E-Mails verwendet",
"maintenance_tasks" => "Wartungsaufgaben",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Επιτυχής επαναφορά κωδικού πρόσβασης",
// Header
"profile" => "Προφίλ",
"dashboard" => "Πίνακας",
"subscriptions" => "Συνδρομές",
"stats" => "Στατιστικές",
"settings" => "Ρυθμίσεις",
"admin" => "Διαχείριση",
"about" => "Για εμάς",
"logout" => "Αποσύνδεση",
// Dashboard
"hello" => "Γειά σου",
"upcoming_payments" => "Επερχόμενες Πληρωμές",
"no_upcoming_payments" => "Δεν έχετε καμία επερχόμενη πληρωμή",
"overdue_renewals" => "Καθυστερημένες Ανανεώσεις",
"ai_recommendations" => "Συστάσεις AI",
"your_budget" => "Ο Προϋπολογισμός σας",
"budget" => "Προϋπολογισμός",
"budget_used" => "Προϋπολογισμός Χρησιμοποιημένος",
"over_budget" => "Πάνω από τον Προϋπολογισμό",
"your_subscriptions" => "Οι Συνδρομές σας",
"your_savings" => "Οι Εξοικονομήσεις σας",
// Subscriptions page
"subscription" => "Συνδρομή",
"no_subscriptions_yet" => "Δεν υπάρχουν καταχωρημένες συνδρομές",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Απόκτησε το κλειδί στο",
"get_free_fixer_api_key" => "Απόκτησε ΔΩΡΕΑΝ Fixer API κλειδί",
"get_key_alternative" => "Εναλλακτικά, μπορείτε να λάβετε ένα δωρεάν κλειδί api fixer από το",
"ai_model" => "AI Μοντέλο",
"select_ai_model" => "Επιλέξτε AI Μοντέλο",
"run_schedule" => "Εκτέλεση προγράμματος",
"manually" => "Χειροκίνητα",
"coming_soon" => "Έρχεται σύντομα",
"invalid_host" => "Μη έγκυρος διακομιστής",
"ai_recommendations_info" => "AI προτάσεις δημιουργούνται με βάση τα συνδρομητικά σας και τα μέλη του νοικοκυριού σας.",
"may_take_time" => "Ανάλογα με τον πάροχο, το μοντέλο και τον αριθμό των συνδρομών, η δημιουργία προτάσεων μπορεί να διαρκέσει κάποιο χρόνο.",
"recommendations_visible_on_dashboard" => "Οι προτάσεις θα είναι ορατές στον πίνακα ελέγχου.",
"generate_recommendations" => "Δημιουργία προτάσεων",
"display_settings" => "Ρυθμίσεις εμφάνισης",
"theme_settings" => "Ρυθμίσεις θέματος",
"colors" => "Χρώματα",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Ρυθμίσεις OIDC",
"oidc_oauth_enabled" => "Ενεργοποίηση OIDC/OAuth",
"create_user_automatically" => "Δημιουργία χρήστη αυτόματα",
"disable_password_login" => "Απενεργοποίηση σύνδεσης με κωδικό πρόσβασης",
"smtp_settings" => "SMTP ρυθμίσεις",
"smtp_usage_info" => "Θα χρησιμοποιηθεί για ανάκτηση κωδικού πρόσβασης και άλλα μηνύματα ηλεκτρονικού ταχυδρομείου συστήματος.",
"maintenance_tasks" => "Εργασίες συντήρησης",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Password reset successful",
// Header
"profile" => "Profile",
"dashboard" => "Dashboard",
"subscriptions" => "Subscriptions",
"stats" => "Statistics",
"settings" => "Settings",
"admin" => "Admin",
"about" => "About",
"logout" => "Logout",
// Dashboard
"hello" => "Hello",
"upcoming_payments" => "Upcoming Payments",
"no_upcoming_payments" => "You don't have any upcoming payments",
"overdue_renewals" => "Overdue Renewals",
"ai_recommendations" => "AI Recommendations",
"your_budget" => "Your Budget",
"budget" => "Budget",
"budget_used" => "Budget Used",
"over_budget" => "Over Budget",
"your_subscriptions" => "Your Subscriptions",
"your_savings" => "Your Savings",
// Subscriptions page
"subscription" => "Subscription",
"no_subscriptions_yet" => "You don't have any subscriptions yet",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "Get your key at",
"get_free_fixer_api_key" => "Get free Fixer API Key",
"get_key_alternative" => "Alternatively, you can get a free fixer api key from",
"ai_model" => "AI Model",
"select_ai_model" => "Select AI Model",
"run_schedule" => "Run Schedule",
"manually" => "Manually",
"coming_soon" => "Coming Soon",
"invalid_host" => "Invalid Host",
"ai_recommendations_info" => "AI Recommendations are generated based on your subscriptions and household members.",
"may_take_time" => "Depending on the provider, model and number of subscriptions, recommendations generation may take some time.",
"recommendations_visible_on_dashboard" => "Recommendations will be visible on the dashboard.",
"generate_recommendations" => "Generate Recommendations",
"display_settings" => "Display Settings",
"theme_settings" => "Theme Settings",
"colors" => "Colors",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "OIDC Settings",
"oidc_oauth_enabled" => "Enable OIDC/OAuth",
"create_user_automatically" => "Create user automatically",
"disable_password_login" => "Disable password login",
"smtp_settings" => "SMTP Settings",
"smtp_usage_info" => "Will be used for password recovery and other system emails.",
"maintenance_tasks" => "Maintenance Tasks",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Contraseña restablecida con éxito",
// Header
"profile" => "Perfil",
"dashboard" => "Tablero",
"subscriptions" => "Suscripciones",
"stats" => "Estadísticas",
"settings" => "Configuración",
"admin" => "Admin",
"about" => "Acerca de",
"logout" => "Cerrar Sesión",
// Dashboard
"hello" => "Hola",
"upcoming_payments" => "Próximos Pagos",
"no_upcoming_payments" => "No tienes pagos próximos",
"overdue_renewals" => "Renovaciones Atrasadas",
"ai_recommendations" => "Recomendaciones de IA",
"your_budget" => "Tu Presupuesto",
"budget" => "Presupuesto",
"budget_used" => "Presupuesto Utilizado",
"over_budget" => "Sobre Presupuesto",
"your_subscriptions" => "Tus Suscripciones",
"your_savings" => "Tus Ahorros",
// Subscriptions page
"subscription" => "Suscripción",
"no_subscriptions_yet" => "Aún no tienes ninguna suscripción",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Obtén tu clave en",
"get_free_fixer_api_key" => "Obtén una API Key de Fixer gratuita",
"get_key_alternative" => "También puede obtener una clave api gratuita de Fixer en",
"ai_model" => "Modelo de IA",
"select_ai_model" => "Seleccionar Modelo de IA",
"run_schedule" => "Ejecutar Programa",
"manually" => "Manualmente",
"coming_soon" => "Próximamente",
"invalid_host" => "Host Inválido",
"ai_recommendations_info" => "Las recomendaciones de IA se generan en función de sus suscripciones y miembros del hogar.",
"may_take_time" => "Dependiendo del proveedor, modelo y número de suscripciones, la generación de recomendaciones puede tardar algún tiempo.",
"recommendations_visible_on_dashboard" => "Las recomendaciones serán visibles en el panel.",
"generate_recommendations" => "Generar Recomendaciones",
"display_settings" => "Configuración de Pantalla",
"theme_settings" => "Configuración de Tema",
"colors" => "Colores",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Configuración OIDC",
"oidc_oauth_enabled" => "Habilitar OIDC/OAuth",
"create_user_automatically" => "Crear usuario automáticamente",
"disable_password_login" => "Deshabilitar inicio de sesión con contraseña",
"smtp_settings" => "Configuración SMTP",
"smtp_usage_info" => "Se utilizará para recuperar contraseñas y otros correos electrónicos del sistema.",
"maintenance_tasks" => "Tareas de Mantenimiento",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Réinitialisation du mot de passe réussie",
// En-tête
"profile" => "Profil",
"dashboard" => "Accueil",
"subscriptions" => "Abonnements",
"stats" => "Statistiques",
"settings" => "Paramètres",
"admin" => "Admin",
"about" => "À propos",
"logout" => "Déconnexion",
// Dashboard
"hello" => "Bonjour",
"upcoming_payments" => "Paiements à venir",
"no_upcoming_payments" => "Vous n'avez aucun paiement à venir",
"overdue_renewals" => "Renouvellements en retard",
"ai_recommendations" => "Recommandations AI",
"your_budget" => "Votre budget",
"budget" => "Budget",
"budget_used" => "Budget Utilisé",
"over_budget" => "Au-dessus du Budget",
"your_subscriptions" => "Vos Abonnements",
"your_savings" => "Vos Économies",
// Page d'abonnements
"subscription" => "Abonnement",
"no_subscriptions_yet" => "Vous n'avez pas encore d'abonnement",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Obtenez votre clé sur",
"get_free_fixer_api_key" => "Obtenez une clé API Fixer gratuite",
"get_key_alternative" => "Vous pouvez également obtenir une clé api de fixation gratuite auprès de",
"ai_model" => "Modèle AI",
"select_ai_model" => "Sélectionner le modèle AI",
"run_schedule" => "Exécuter le programme",
"manually" => "Manuellement",
"coming_soon" => "À venir",
"invalid_host" => "Hôte invalide",
"ai_recommendations_info" => "Les recommandations de l'IA sont générées en fonction de vos abonnements et des membres de votre foyer.",
"may_take_time" => "En fonction du fournisseur, du modèle et du nombre d'abonnements, la génération de recommandations peut prendre un certain temps.",
"recommendations_visible_on_dashboard" => "Les recommandations seront visibles sur le tableau de bord.",
"generate_recommendations" => "Générer des recommandations",
"display_settings" => "Paramètres d'affichage",
"theme_settings" => "Paramètres de thème",
"colors" => "Couleurs",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Paramètres OIDC",
"oidc_auth_enabled" => "Authentification OIDC activée",
"create_user_automatically" => "Créer un utilisateur automatiquement",
"disable_password_login" => "Désactiver la connexion par mot de passe",
"smtp_settings" => "Paramètres SMTP",
"smtp_usage_info" => "Sera utilisé pour la récupération du mot de passe et d'autres e-mails système.",
"maintenance_tasks" => "Tâches de maintenance",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Pengaturan ulang kata sandi berhasil",
// Header
"profile" => "Profil",
"dashboard" => "Dasbor",
"subscriptions" => "Langganan",
"stats" => "Statistik",
"settings" => "Pengaturan",
"admin" => "Admin",
"about" => "Tentang",
"logout" => "Keluar",
// Dashboard
"hello" => "Halo",
"upcoming_payments" => "Pembayaran Mendatang",
"no_upcoming_payments" => "Anda tidak memiliki pembayaran mendatang",
"overdue_renewals" => "Perpanjangan Terlambat",
"ai_recommendations" => "Rekomendasi AI",
"your_budget" => "Anggaran Anda",
"budget" => "Anggaran",
"budget_used" => "Anggaran Digunakan",
"over_budget" => "Di Atas Anggaran",
"your_subscriptions" => "Langganan Anda",
"your_savings" => "Tabungan Anda",
// Subscriptions page
"subscription" => "Langganan",
"no_subscriptions_yet" => "Anda belum memiliki langganan",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "Dapatkan kunci Anda di",
"get_free_fixer_api_key" => "Dapatkan Kunci API Fixer gratis",
"get_key_alternative" => "Sebagai alternatif, Anda bisa mendapatkan kunci api fixer gratis dari",
"ai_model" => "Model AI",
"select_ai_model" => "Pilih Model AI",
"run_schedule" => "Jadwalkan Eksekusi",
"manually" => "Secara Manual",
"coming_soon" => "Segera Hadir",
"invalid_host" => "Host Tidak Valid",
"ai_recommendations_info" => "Rekomendasi AI dihasilkan berdasarkan langganan dan anggota rumah tangga Anda.",
"may_take_time" => "Bergantung pada penyedia, model, dan jumlah langganan, pembuatan rekomendasi mungkin memerlukan waktu.",
"recommendations_visible_on_dashboard" => "Rekomendasi akan terlihat di dasbor.",
"generate_recommendations" => "Hasilkan Rekomendasi",
"display_settings" => "Pengaturan Tampilan",
"theme_settings" => "Pengaturan Tema",
"colors" => "Warna",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "Pengaturan OIDC",
"oidc_oauth_enabled" => "Aktifkan OIDC/OAuth",
"create_user_automatically" => "Buat pengguna secara otomatis",
"disable_password_login" => "Nonaktifkan masuk dengan kata sandi",
"smtp_settings" => "Pengaturan SMTP",
"smtp_usage_info" => "Akan digunakan untuk pemulihan kata sandi dan email sistem lainnya.",
"maintenance_tasks" => "Tugas Pemeliharaan",

View File

@@ -36,6 +36,7 @@ $i18n = [
// Header
"profile" => 'Profilo',
"dashboard" => 'Dashboard',
"subscriptions" => 'Abbonamenti',
"stats" => 'Statistiche',
"settings" => 'Impostazioni',
@@ -43,10 +44,23 @@ $i18n = [
"about" => 'Informazioni',
"logout" => 'Esci',
// Dashboard
"hello" => "Ciao",
"upcoming_payments" => "Pagamenti in arrivo",
"no_upcoming_payments" => "Non hai pagamenti in arrivo",
"overdue_renewals" => "Rinnovi scaduti",
"ai_recommendations" => "Raccomandazioni AI",
"your_budget" => "Il tuo budget",
"budget" => "Budget",
"budget_used" => "Budget Utilizzato",
"over_budget" => "Sopra il Budget",
"your_subscriptions" => "I tuoi Abbonamenti",
"your_savings" => "I tuoi Risparmi",
// Subscriptions
"subscription" => 'Abbonamento',
"no_subscriptions_yet" => 'Non hai ancora nessun abbonamento',
"add_first_subscription" => 'Aggiungo il tuo primo abbonamento',
"add_first_subscription" => 'Aggiungi il tuo primo abbonamento',
"new_subscription" => 'Nuovo abbonamento',
"search" => 'Cerca',
"state" => "Stato",
@@ -223,6 +237,16 @@ $i18n = [
"get_key" => 'Ottieni la tua chiave su',
"get_free_fixer_api_key" => 'Ottieni gratuitamente la chiave API di Fixer',
"get_key_alternative" => 'In alternativa, puoi ottenere gratuitamente una chiave API di Fixer da',
"ai_model" => "Modello AI",
"select_ai_model" => "Seleziona Modello AI",
"run_schedule" => "Esegui Programma",
"manually" => "Manuale",
"coming_soon" => "In Arrivo",
"invalid_host" => "Host Non Valido",
"ai_recommendations_info" => "Le raccomandazioni dell'IA sono generate in base ai tuoi abbonamenti e ai membri del tuo nucleo familiare.",
"may_take_time" => "A seconda del fornitore, del modello e del numero di abbonamenti, la generazione delle raccomandazioni potrebbe richiedere del tempo.",
"recommendations_visible_on_dashboard" => "Le raccomandazioni saranno visibili sul dashboard.",
"generate_recommendations" => "Genera Raccomandazioni",
"display_settings" => 'Impostazioni di visualizzazione',
"theme_settings" => 'Impostazioni del tema',
"colors" => 'Colori',
@@ -369,6 +393,7 @@ $i18n = [
"oidc_settings" => "Impostazioni OIDC",
"oidc_auth_enabled" => "Autenticazione OIDC abilitata",
"create_user_automatically" => "Crea utente automaticamente",
"disable_password_login" => "Disabilita la connessione con password",
"smtp_settings" => "Impostazioni SMTP",
"smtp_usage_info" => "Verrà utilizzato per il recupero della password e altre e-mail di sistema.",
"maintenance_tasks" => "Compiti di manutenzione",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "パスワードリセットに成功",
// Header
"profile" => "プロフィール",
"dashboard" => "ダッシュボード",
"subscriptions" => "定期購入",
"stats" => "統計",
"settings" => "設定",
"admin" => "管理者",
"about" => "About",
"logout" => "ログアウト",
// Dashboard
"hello" => "こんにちは",
"upcoming_payments" => "今後の支払い",
"no_upcoming_payments" => "今後の支払いはありません",
"overdue_renewals" => "期限切れの更新",
"ai_recommendations" => "AIによる推奨",
"your_budget" => "あなたの予算",
"budget" => "予算",
"budget_used" => "予算の使用",
"over_budget" => "予算オーバー",
"your_subscriptions" => "あなたの定期購入",
"your_savings" => "あなたの貯蓄",
// Subscriptions page
"subscription" => "定期購入",
"no_subscriptions_yet" => "まだ定期購入がありません",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "キーを入手する",
"get_free_fixer_api_key" => "無料のFixer APIキーを取得",
"get_key_alternative" => "または、以下のサイトから無料のフィクサーapiキーを入手することもできます。",
"ai_model" => "AIモデル",
"select_ai_model" => "AIモデルを選択",
"run_schedule" => "スケジュールを実行",
"manually" => "手動",
"coming_soon" => "近日公開",
"invalid_host" => "無効なホスト",
"ai_recommendations_info" => "AIによる推奨は、あなたの定期購入と世帯メンバーに基づいて生成されます。",
"may_take_time" => "プロバイダ、モデル、定期購入数によっては、推奨の生成に時間がかかる場合があります。",
"recommendations_visible_on_dashboard" => "推奨はダッシュボードに表示されます。",
"generate_recommendations" => "推奨",
"display_settings" => "表示設定",
"theme_settings" => "テーマ設定",
"colors" => "",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "OIDC設定",
"oidc_auth_enabled" => "OIDC認証を有効にする",
"create_user_automatically" => "OIDCユーザーを自動的に作成する",
"disable_password_login" => "パスワードログインを無効にする",
"smtp_settings" => "SMTP設定",
"smtp_usage_info" => "パスワードの回復やその他のシステム電子メールに使用されます。",
"maintenance_tasks" => "メンテナンスタスク",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "비밀번호 재설정 성공",
// Header
"profile" => "프로필",
"dashboard" => "대시보드",
"subscriptions" => "구독",
"stats" => "통계",
"settings" => "설정",
"admin" => "관리자",
"about" => "정보",
"logout" => "로그아웃",
// Dashboard
"hello" => "안녕하세요",
"upcoming_payments" => "예정된 결제",
"no_upcoming_payments" => "예정된 결제가 없습니다.",
"overdue_renewals" => "연체 갱신",
"ai_recommendations" => "AI 추천",
"your_budget" => "당신의 예산",
"budget" => "예산",
"budget_used" => "예산 사용",
"over_budget" => "예산 초과",
"your_subscriptions" => "당신의 구독",
"your_savings" => "당신의 저축",
// Subscriptions page
"subscription" => "구독",
"no_subscriptions_yet" => "아직 구독을 등록하지 않았습니다.",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "키 얻기",
"get_free_fixer_api_key" => "무료 Fixer API 키 얻기",
"get_key_alternative" => "또는 다음 사이트에서 무료 Fixer api 키를 얻을 수 있습니다.",
"ai_model" => "AI 모델",
"select_ai_model" => "AI 모델 선택",
"run_schedule" => "일정 실행",
"manually" => "수동으로",
"coming_soon" => "곧 출시 예정",
"invalid_host" => "유효하지 않은 호스트",
"ai_recommendations_info" => "AI 추천은 사용자의 구독과 가구 구성원을 기반으로 생성됩니다.",
"may_take_time" => "제공자, 모델, 구독 수에 따라 추천 생성에 시간이 걸릴 수 있습니다.",
"recommendations_visible_on_dashboard" => "추천은 대시보드에서 확인할 수 있습니다.",
"generate_recommendations" => "추천 생성",
"display_settings" => "디스플레이 설정",
"theme_settings" => "테마 설정",
"colors" => "색상",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "OIDC 설정",
"oidc_auth_enabled" => "OIDC 인증 활성화",
"create_user_automatically" => "사용자 자동 생성",
"disable_password_login" => "비밀번호 로그인 비활성화",
"smtp_settings" => "SMTP 설정",
"smtp_usage_info" => "비밀번호 복구 및 기타 시스템 이메일에 사용됩니다.",
"maintenance_tasks" => "유지보수 작업",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Wachtwoord reset succesvol",
// Header
"profile" => "Profiel",
"dashboard" => "Dashboard",
"subscriptions" => "Abonnementen",
"stats" => "Statistieken",
"settings" => "Instellingen",
"admin" => "Beheer",
"about" => "Over",
"logout" => "Uitloggen",
// Dashboard
"hello" => "Hallo",
"upcoming_payments" => "Aankomende Betalingen",
"no_upcoming_payments" => "Je hebt geen aankomende betalingen",
"overdue_renewals" => "Verlopen Verlengen",
"ai_recommendations" => "AI Aanbevelingen",
"your_budget" => "Je Budget",
"budget" => "Budget",
"budget_used" => "Budget Gebruikt",
"over_budget" => "Over Budget",
"your_subscriptions" => "Je Abonnementen",
"your_savings" => "Je Besparingen",
// Subscriptions page
"subscription" => "Abonnement",
"no_subscriptions_yet" => "Je hebt nog geen abonnementen",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "Haal je sleutel op bij",
"get_free_fixer_api_key" => "Krijg gratis Fixer API-sleutel",
"get_key_alternative" => "Als alternatief kun je een gratis Fixer API-sleutel krijgen van",
"ai_model" => "AI-model",
"select_ai_model" => "Selecteer AI-model",
"run_schedule" => "Uitvoerschema",
"manually" => "Handmatig",
"coming_soon" => "Binnenkort beschikbaar",
"invalid_host" => "Ongeldige host",
"ai_recommendations_info" => "AI-aanbevelingen worden gegenereerd op basis van je abonnementen en huishoudleden.",
"may_take_time" => "Afhankelijk van de aanbieder, het model en het aantal abonnementen kan het genereren van aanbevelingen enige tijd duren.",
"recommendations_visible_on_dashboard" => "Aanbevelingen zijn zichtbaar op het dashboard.",
"generate_recommendations" => "Genereer aanbevelingen",
"display_settings" => "Weergave-instellingen",
"theme_settings" => "Thema-instellingen",
"colors" => "Kleuren",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "OIDC-instellingen",
"oidc_oauth_enabled" => "OIDC/OAuth inschakelen",
"create_user_automatically" => "Gebruiker automatisch aanmaken",
"disable_password_login" => "Wachtwoordlogin uitschakelen",
"smtp_settings" => "SMTP-instellingen",
"smtp_usage_info" => "Wordt gebruikt voor wachtwoordherstel en andere systeem e-mails.",
"maintenance_tasks" => "Onderhoudstaken",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Hasło zostało zresetowane pomyślnie",
// Header
"profile" => "Profil",
"dashboard" => "Panel",
"subscriptions" => "Subskrypcje",
"stats" => "Statystyki",
"settings" => "Ustawienia",
"admin" => "Admin",
"about" => "O aplikacji",
"logout" => "Wyloguj się",
// Dashboard
"hello" => "Cześć",
"upcoming_payments" => "Nadchodzące płatności",
"no_upcoming_payments" => "Nie masz żadnych nadchodzących płatności",
"overdue_renewals" => "Zaległe odnowienia",
"ai_recommendations" => "Rekomendacje AI",
"your_budget" => "Twój budżet",
"budget" => "Budżet",
"budget_used" => "Budżet użyty",
"over_budget" => "Przekroczony budżet",
"your_subscriptions" => "Twoje subskrypcje",
"your_savings" => "Twoje oszczędności",
// Subscriptions page
"subscription" => "Subskrypcja",
"no_subscriptions_yet" => "Nie masz jeszcze żadnych subskrypcji",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Zdobądź klucz na stronie",
"get_free_fixer_api_key" => "Uzyskaj bezpłatny klucz API Fixer'a",
"get_key_alternative" => "Alternatywnie, możesz uzyskać darmowy klucz api fixer'a od",
"ai_model" => "Model AI",
"select_ai_model" => "Wybierz model AI",
"run_schedule" => "Harmonogram uruchamiania",
"manually" => "Ręcznie",
"coming_soon" => "Wkrótce dostępne",
"invalid_host" => "Nieprawidłowy host",
"ai_recommendations_info" => "Rekomendacje AI są generowane na podstawie Twoich subskrypcji i członków gospodarstwa domowego.",
"may_take_time" => "W zależności od dostawcy, modelu i liczby subskrypcji, generowanie rekomendacji może zająć trochę czasu.",
"recommendations_visible_on_dashboard" => "Rekomendacje będą widoczne na pulpicie.",
"generate_recommendations" => "Generuj rekomendacje",
"display_settings" => "Ustawienia wyświetlania",
"theme_settings" => "Ustawienia motywu",
"colors" => "Kolory",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Ustawienia OIDC",
"oidc_auth_enabled" => "Włącz uwierzytelnianie OIDC",
"create_user_automatically" => "Automatycznie twórz użytkowników",
"disable_password_login" => "Wyłącz logowanie za pomocą hasła",
"smtp_settings" => "Ustawienia SMTP",
"smtp_usage_info" => "Będzie używany do odzyskiwania hasła i innych e-maili systemowych.",
"maintenance_tasks" => "Zadania konserwacyjne",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Password reposta com sucesso",
// Header
"profile" => "Perfil",
"dashboard" => "Painel",
"subscriptions" => "Subscrições",
"stats" => "Estatísticas",
"settings" => "Definições",
"admin" => "Administração",
"about" => "Sobre",
"logout" => "Terminar Sessão",
// Dashboard
"hello" => "Olá",
"upcoming_payments" => "Próximos Pagamentos",
"no_upcoming_payments" => "Você não tem pagamentos agendados",
"overdue_renewals" => "Renovações Atrasadas",
"ai_recommendations" => "Recomendações de IA",
"your_budget" => "Seu Orçamento",
"budget" => "Orçamento",
"budget_used" => "Orçamento Usado",
"over_budget" => "Acima do Orçamento",
"your_subscriptions" => "Suas subscrições",
"your_savings" => "Suas Poupanças",
// Subscriptions page
"subscription" => "Subscrição",
"no_subscriptions_yet" => "Ainda não tem subscrições",
@@ -216,7 +229,17 @@ $i18n = [
"get_key" => "Obtenha a sua API Key em",
"get_free_fixer_api_key" => "Obtenha a sua API Key grátis do Fixer",
"get_key_alternative" => "Como alternativa obtenha a sua API Key em",
"display_settings" => "Definições de visualização",
"ai_model" => "Modelo de IA",
"select_ai_model" => "Selecionar modelo de IA",
"run_schedule" => "Agendamento de execução",
"manually" => "Manual",
"coming_soon" => "Em breve",
"invalid_host" => "Host inválido",
"ai_recommendations_info" => "As recomendações de IA são geradas com base nas suas assinaturas e membros da família.",
"may_take_time" => "Dependendo do provedor, modelo e número de assinaturas, a geração de recomendações pode levar algum tempo.",
"recommendations_visible_on_dashboard" => "As recomendações serão visíveis no painel.",
"generate_recommendations" => "Gerar recomendações",
"display_settings" => "Definições de exibição",
"theme_settings" => "Definições de Tema",
"colors" => "Cores",
"custom_colors" => "Cores Personalizadas",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Definições OIDC",
"oidc_auth_enabled" => "Activar autenticação OIDC",
"create_user_automatically" => "Criar utilizador automaticamente",
"disable_password_login" => "Desactivar login por password",
"smtp_settings" => "Definições SMTP",
"smtp_usage_info" => "Será usado para recuperações de password e outros emails do sistema.",
"maintenance_tasks" => "Tarefas de Manutenção",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Senha redefinida com sucesso",
// Header
"profile" => "Perfil",
"dashboard" => "Painel",
"subscriptions" => "Assinaturas",
"stats" => "Estatísticas",
"settings" => "Configurações",
"admin" => "Admin",
"about" => "Sobre",
"logout" => "Sair",
// Dashboard
"hello" => "Olá",
"upcoming_payments" => "Pagamentos Futuros",
"no_upcoming_payments" => "Você não tem pagamentos futuros",
"overdue_renewals" => "Renovações Atrasadas",
"ai_recommendations" => "Recomendações de IA",
"your_budget" => "Seu Orçamento",
"budget" => "Orçamento",
"budget_used" => "Orçamento Usado",
"over_budget" => "Acima do Orçamento",
"your_subscriptions" => "Suas Assinaturas",
"your_savings" => "Suas Economias",
// Subscriptions page
"subscription" => "Assinatura",
"no_subscriptions_yet" => "Você ainda não tem nenhuma assinatura",
@@ -216,7 +229,17 @@ $i18n = [
"get_key" => "Obtenha a sua chave em",
"get_free_fixer_api_key" => "Obtenha a sua chave API do Fixer gratuitamente",
"get_key_alternative" => "Como alternativa, você pode obter uma chave de API grátis em",
"display_settings" => "Configurações de visualização",
"ai_model" => "Modelo de IA",
"select_ai_model" => "Selecionar modelo de IA",
"run_schedule" => "Agendamento de execução",
"manually" => "Manual",
"coming_soon" => "Em breve",
"invalid_host" => "Host inválido",
"ai_recommendations_info" => "As recomendações de IA são geradas com base em suas assinaturas e membros da família.",
"may_take_time" => "Dependendo do provedor, modelo e número de assinaturas, a geração de recomendações pode levar algum tempo.",
"recommendations_visible_on_dashboard" => "As recomendações serão visíveis no painel.",
"generate_recommendations" => "Gerar recomendações",
"display_settings" => "Configurações de exibição",
"theme_settings" => "Configurações de tema",
"colors" => "Cores",
"custom_colors" => "Cores personalizadas",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Configurações OIDC",
"oidc_auth_enabled" => "Habilitar autenticação OIDC",
"create_user_automatically" => "Criar usuário automaticamente",
"disable_password_login" => "Desativar login por senha",
"smtp_settings" => "Configurações SMTP",
"smtp_usage_info" => "Será usado para recuperação de senha e outros e-mails do sistema.",
"maintenance_tasks" => "Tarefas de manutenção",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Пароль успешно сброшен",
// Header
"profile" => "Профиль",
"dashboard" => "Панель",
"subscriptions" => "Подписки",
"stats" => "Статистика",
"settings" => "Настройки",
"admin" => "Администратор",
"about" => "О программе",
"logout" => "Выйти",
// Dashboard
"hello" => "Привет",
"upcoming_payments" => "Предстоящие платежи",
"no_upcoming_payments" => "У вас нет предстоящих платежей",
"overdue_renewals" => "Просроченные продления",
"ai_recommendations" => "Рекомендации ИИ",
"your_budget" => "Ваш бюджет",
"budget" => "Бюджет",
"budget_used" => "Использованный бюджет",
"over_budget" => "Превышение бюджета",
"your_subscriptions" => "Ваши подписки",
"your_savings" => "Ваши сбережения",
// Subscriptions page
"subscription" => "Подписка",
"no_subscriptions_yet" => "У вас пока нет подписок",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Получите ключ по адресу",
"get_free_fixer_api_key" => "Получите бесплатный ключ API Fixer",
"get_key_alternative" => "Кроме того, вы можете получить бесплатный ключ API Fixer на сайте",
"ai_model" => "Модель ИИ",
"select_ai_model" => "Выбрать модель ИИ",
"run_schedule" => "Запустить расписание",
"manually" => "Вручную",
"coming_soon" => "Скоро",
"invalid_host" => "Неверный хост",
"ai_recommendations_info" => "Рекомендации ИИ генерируются на основе ваших подписок и членов семьи.",
"may_take_time" => "В зависимости от провайдера, модели и количества подписок генерация рекомендаций может занять некоторое время.",
"recommendations_visible_on_dashboard" => "Рекомендации будут видны на панели управления.",
"generate_recommendations" => "Сгенерировать рекомендации",
"display_settings" => "Настройки отображения",
"theme_settings" => "Настройки темы",
"colors" => "Цвета",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "Настройки OIDC",
"oidc_auth_enabled" => "Включить OIDC аутентификацию",
"create_user_automatically" => "Автоматически создавать пользователей",
"disable_password_login" => "Отключить вход по паролю",
"smtp_settings" => "Настройки SMTP",
"smtp_usage_info" => "Будет использоваться для восстановления пароля и других системных писем.",
"maintenance_tasks" => "Задачи обслуживания",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Ponastavitev gesla je uspela",
// Header
"profile" => "Profil",
"dashboard" => "Panel",
"subscriptions" => "Naročnine",
"stats" => "Statistika",
"settings" => "Nastavitve",
"admin" => "Skrbnik",
"about" => "O programu",
"logout" => "Odjava",
// Dashboard
"hello" => "Pozdravljen",
"upcoming_payments" => "Prihajajoča plačila",
"no_upcoming_payments" => "Nimate prihodnjih plačil",
"overdue_renewals" => "Zapadla podaljšanja",
"ai_recommendations" => "Priporočila AI",
"your_budget" => "Vaš proračun",
"budget" => "Proračun",
"budget_used" => "Porabljen proračun",
"over_budget" => "Prekoračen proračun",
"your_subscriptions" => "Vaše naročnine",
"your_savings" => "Vaše prihranke",
// Subscriptions page
"subscription" => "Naročnina",
"no_subscriptions_yet" => "Nimate še nobene naročnine",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Pridobite svoj ključ pri",
"get_free_fixer_api_key" => "Pridobite brezplačen ključ API Fixer",
"get_key_alternative" => "Lahko pa tudi dobite brezplačni Fixer API od",
"ai_model" => "Model AI",
"select_ai_model" => "Izberite model AI",
"run_schedule" => "Zaženi urnik",
"manually" => "Ročno",
"coming_soon" => "Kmalu",
"invalid_host" => "Neveljavna gostiteljska naprava",
"ai_recommendations_info" => "Priporočila AI so generirana na podlagi vaših naročnin in članov gospodinjstva.",
"may_take_time" => "Odvisno od ponudnika, modela in števila naročnin lahko generiranje priporočil traja nekaj časa.",
"recommendations_visible_on_dashboard" => "Priporočila bodo vidna na nadzorni plošči.",
"generate_recommendations" => "Generiraj priporočila",
"display_settings" => "Nastavitve zaslona",
"theme_settings" => "Nastavitve teme",
"colors" => "Barve",
@@ -344,6 +367,7 @@ $i18n = [
"oidc_settings" => "OIDC nastavitve",
"oidc_auth_enabled" => "Omogoči OIDC prijavo",
"create_user_automatically" => "Samodejno ustvari uporabnika",
"disable_password_login" => "Onemogoči prijavo z geslom",
"smtp_settings" => "Nastavitve SMTP",
"smtp_usage_info" => "Uporabljeno bo za obnovitev gesla in druge sistemske e-pošte.",
"maintenance_tasks" => "Vzdrževalne naloge",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Ресетовање лозинке је успешно",
// Header
"profile" => "Профил",
"dashboard" => "Панел",
"subscriptions" => "Претплате",
"stats" => "Статистике",
"settings" => "Подешавања",
"admin" => "Админ",
"about" => "О апликацији",
"logout" => "Одјава",
// Dashboard
"hello" => "Здраво",
"upcoming_payments" => "Предстојећа плаћања",
"no_upcoming_payments" => "Немате предстојећих плаћања",
"overdue_renewals" => "Засадне обнове",
"ai_recommendations" => "AI препоруке",
"your_budget" => "Ваш буџет",
"budget" => "Буџет",
"budget_used" => "Искоришћен буџет",
"over_budget" => "Прекорачен буџет",
"your_subscriptions" => "Ваше претплате",
"your_savings" => "Ваша уштеда",
// Страница са претплатама
"subscription" => "Претплата",
"no_subscriptions_yet" => "Још увек немате ниједну претплату",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Добијте свој кључ на",
"get_free_fixer_api_key" => "Добијте бесплатни Fixer API кључ",
"get_key_alternative" => "Алтернативно, можете добити бесплатни Fixer API кључ са",
"ai_model" => "AI Модел",
"select_ai_model" => "Изаберите AI Модел",
"run_schedule" => "Покрените распоред",
"manually" => "Ручно",
"coming_soon" => "Ускоро",
"invalid_host" => "Неважећи хост",
"ai_recommendations_info" => "AI препоруке се генеришу на основу ваших претплата и чланова домаћинства.",
"may_take_time" => "У зависности од провајдера, модела и броја претплата, генерисање препорука може потрајати.",
"recommendations_visible_on_dashboard" => "Препоруке ће бити видљиве на контролној табли.",
"generate_recommendations" => "Генериши препоруке",
"display_settings" => "Подешавања приказа",
"theme_settings" => "Подешавања теме",
"colors" => "Боје",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "OIDC подешавања",
"oidc_auth_enabled" => "OIDC аутентификација је омогућена",
"create_user_automatically" => "Креирај корисника аутоматски",
"disable_password_login" => "Онемогући пријаву лозинком",
"smtp_settings" => "SMTP подешавања",
"smtp_usage_info" => "SMTP се користи за слање е-поште за обавештења.",
"maintenance_tasks" => "Одржавање",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Lozinka uspešno resetovana",
// Header
"profile" => "Profil",
"dashboard" => "Panel",
"subscriptions" => "Pretplate",
"stats" => "Statistike",
"settings" => "Podešavanja",
"admin" => "Admin",
"about" => "O aplikaciji",
"logout" => "Odjava",
// Dashboard
"hello" => "Zdravo",
"upcoming_payments" => "Predstojeće uplate",
"no_upcoming_payments" => "Nemate predstojećih uplata",
"overdue_renewals" => "Zakasne obnove",
"ai_recommendations" => "AI preporuke",
"your_budget" => "Vaš budžet",
"budget" => "Budžet",
"budget_used" => "Iskorišćen budžet",
"over_budget" => "Prekoračen budžet",
"your_subscriptions" => "Vaše pretplate",
"your_savings" => "Vaša ušteda",
// Stranica sa pretplatama
"subscription" => "Pretplata",
"no_subscriptions_yet" => "Još uvek nemate nijednu pretplatu",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Pronađite svoj ključ na",
"get_free_fixer_api_key" => "Pronađite besplatni Fixer API ključ",
"get_key_alternative" => "Alternativno, možete dobiti besplatni Fixer API ključ na",
"ai_model" => "AI Model",
"select_ai_model" => "Izaberite AI Model",
"run_schedule" => "Pokreni raspored",
"manually" => "Ručno",
"coming_soon" => "Uskoro",
"invalid_host" => "Nevažeći host",
"ai_recommendations_info" => "AI preporuke se generišu na osnovu vaših pretplata i članova domaćinstva.",
"may_take_time" => "U zavisnosti od provajdera, modela i broja pretplata, generisanje preporuka može potrajati.",
"recommendations_visible_on_dashboard" => "Preporuke će biti vidljive na kontrolnoj tabli.",
"generate_recommendations" => "Generiši preporuke",
"display_settings" => "Podešavanja prikaza",
"theme_settings" => "Podešavanja teme",
"colors" => "Boje",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "OIDC podešavanja",
"oidc_auth_enabled" => "Omogući OIDC autentifikaciju",
"create_user_automatically" => "Kreiraj korisnika automatski",
"disable_password_login" => "Onemoguči prijavu z geslom",
"smtp_settings" => "SMTP podešavanja",
"smtp_usage_info" => "Koristiće se za oporavak lozinke i druge sistemske e-poruke.",
"maintenance_tasks" => "Održavanje",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Şifre sıfırlama başarılı",
// Header
"profile" => "Profil",
"dashboard" => "Panel",
"subscriptions" => "Abonelikler",
"stats" => "İstatistikler",
"settings" => "Ayarlar",
"admin" => "Yönetici",
"about" => "Hakkında",
"logout" => "Çıkış Yap",
// Dashboard
"hello" => "Merhaba",
"upcoming_payments" => "Yaklaşan Ödemeler",
"no_upcoming_payments" => "Yaklaşan ödemeniz yok",
"overdue_renewals" => "Gecikmiş Yenilemeler",
"ai_recommendations" => "AI Önerileri",
"your_budget" => "Bütçeniz",
"budget" => "Bütçe",
"budget_used" => "İşletilen Bütçe",
"over_budget" => "Bütçeyi Aşma",
"your_subscriptions" => "Abonelikleriniz",
"your_savings" => "Tasarruflarınız",
// Subscriptions page
"subscription" => "Abonelik",
"no_subscriptions_yet" => "Henüz herhangi bir aboneliğiniz yok",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Anahtarınızı şuradan alın",
"get_free_fixer_api_key" => "Ücretsiz Fixer API Anahtarı alın",
"get_key_alternative" => "Alternatif olarak, şu adresten ücretsiz bir fixer api anahtarı edinebilirsiniz",
"ai_model" => "AI Modeli",
"select_ai_model" => "AI Modelini Seçin",
"run_schedule" => "Programı Çalıştır",
"manually" => "Manuel Olarak",
"coming_soon" => "Çok Yakında",
"invalid_host" => "Geçersiz Host",
"ai_recommendations_info" => "AI önerileri, abonelikleriniz ve hane üyeleriniz temel alınarak oluşturulur.",
"may_take_time" => "Sağlayıcıya, modele ve abonelik sayısına bağlı olarak önerilerin oluşturulması biraz zaman alabilir.",
"recommendations_visible_on_dashboard" => "Öneriler panelde görüntülenecektir.",
"generate_recommendations" => "Önerileri Oluştur",
"display_settings" => "Görüntüleme Ayarları",
"theme_settings" => "Tema Ayarları",
"colors" => "Renkler",
@@ -351,6 +374,7 @@ $i18n = [
"oidc_settings" => "OpenID Connect Ayarları",
"oidc_auth_enabled" => "OpenID Connect Kimlik Doğrulaması Etkinleştirildi",
"create_user_automatically" => "OpenID Connect ile giriş yapıldığında kullanıcı otomatik olarak oluşturulsun",
"disable_password_login" => "Parola ile giriş devre dışı bırakılsın",
"smtp_settings" => "SMTP Ayarları",
"smtp_usage_info" => "Şifre kurtarma ve diğer sistem e-postaları için kullanılacaktır.",
"maintenance_tasks" => "Bakım Görevleri",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Пароль успішно скинуто",
// Header
"profile" => "Профіль",
"dashboard" => "Панель",
"subscriptions" => "Підписки",
"stats" => "Статистика",
"settings" => "Налаштування",
"admin" => "Адміністратор",
"about" => "Про програму",
"logout" => "Вийти",
// Dashboard
"hello" => "Привіт",
"upcoming_payments" => "Предстоять платежі",
"no_upcoming_payments" => "У вас немає предстоять платежів",
"overdue_renewals" => "Прострочені поновлення",
"ai_recommendations" => "AI рекомендації",
"your_budget" => "Ваш бюджет",
"budget" => "Бюджет",
"budget_used" => "Використаний бюджет",
"over_budget" => "Перевищений бюджет",
"your_subscriptions" => "Ваші підписки",
"your_savings" => "Ваші заощадження",
// Subscriptions page
"subscription" => "Підписка",
"no_subscriptions_yet" => "У вас поки немає підписок",
@@ -216,6 +229,16 @@ $i18n = [
"get_key" => "Отримайте ключ за адресою",
"get_free_fixer_api_key" => "Отримайте безкоштовний ключ API Fixer",
"get_key_alternative" => "Крім того, ви можете отримати безкоштовний ключ API Fixer на сайті",
"ai_model" => "AI Модель",
"select_ai_model" => "Виберіть AI Модель",
"run_schedule" => "Запустити Розклад",
"manually" => "Вручну",
"coming_soon" => "Незабаром",
"invalid_host" => "Неправильний Хост",
"ai_recommendations_info" => "AI рекомендації створюються на основі ваших підписок та членів домогосподарства.",
"may_take_time" => "В залежності від постачальника, моделі та кількості підписок, створення рекомендацій може зайняти деякий час.",
"recommendations_visible_on_dashboard" => "Рекомендації будуть видимі на панелі приладів.",
"generate_recommendations" => "Створити рекомендації",
"display_settings" => "Налаштування відображення",
"theme_settings" => "Налаштування теми",
"colors" => "Кольори",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "Налаштування OIDC",
"oidc_auth_enabled" => "Увімкнути OIDC автентифікацію",
"create_user_automatically" => "Автоматично створювати користувача при вході",
"disable_password_login" => "Відключити вхід за паролем",
"smtp_usage_info" => "Буде використовуватися для відновлення пароля та інших системних листів.",
"maintenance_tasks" => "Завдання обслуговування",
"orphaned_logos" => "Втрачений логотип",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "Đặt lại mật khẩu thành công",
// Header
"profile" => "Hồ sơ",
"dashboard" => "Bảng",
"subscriptions" => "Đăng ký",
"stats" => "Thống kê",
"settings" => "Cài đặt",
"admin" => "Quản trị viên",
"about" => "Giới thiệu",
"logout" => "Đăng xuất",
// Dashboard
"hello" => "Xin chào",
"upcoming_payments" => "Các khoản thanh toán sắp tới",
"no_upcoming_payments" => "Bạn không có khoản thanh toán nào sắp tới",
"overdue_renewals" => "Gia hạn quá hạn",
"ai_recommendations" => "Khuyến nghị AI",
"your_budget" => "Ngân sách của bạn",
"budget" => "Ngân sách",
"budget_used" => "Ngân sách đã sử dụng",
"over_budget" => "Vượt ngân sách",
"your_subscriptions" => "Đăng ký của bạn",
"your_savings" => "Tiết kiệm của bạn",
// Subscriptions page
"subscription" => "Đăng ký",
"no_subscriptions_yet" => "Bạn chưa có đăng ký nào",
@@ -217,6 +230,16 @@ $i18n = [
"get_key" => "Nhận khóa của bạn tại",
"get_free_fixer_api_key" => "Nhận API Key Fixer miễn phí",
"get_key_alternative" => "Ngoài ra, bạn có thể nhận API Key Fixer miễn phí từ",
"ai_model" => "Mô hình AI",
"select_ai_model" => "Chọn Mô hình AI",
"run_schedule" => "Chạy Lịch Trình",
"manually" => "Thủ Công",
"coming_soon" => "Sắp Có",
"invalid_host" => "Máy Chủ Không Hợp Lệ",
"ai_recommendations_info" => "Các đề xuất AI được tạo dựa trên các đăng ký và thành viên hộ gia đình của bạn.",
"may_take_time" => "Tùy thuộc vào nhà cung cấp, mô hình và số lượng đăng ký, việc tạo đề xuất có thể mất một khoảng thời gian.",
"recommendations_visible_on_dashboard" => "Các đề xuất sẽ hiển thị trên bảng điều khiển.",
"generate_recommendations" => "Tạo Đề Xuất",
"display_settings" => "Cài đặt hiển thị",
"theme_settings" => "Cài đặt giao diện",
"colors" => "Màu sắc",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "Cài đặt OIDC",
"oidc_auth_enabled" => "Xác thực OIDC đã được bật",
"create_user_automatically" => "Tạo người dùng tự động",
"disable_password_login" => "Vô hiệu hóa đăng nhập bằng mật khẩu",
"smtp_settings" => "Cài đặt SMTP",
"smtp_usage_info" => "Sẽ được sử dụng cho việc khôi phục mật khẩu và các email hệ thống khác.",
"maintenance_tasks" => "Nhiệm vụ bảo trì",

View File

@@ -36,6 +36,7 @@ $i18n = [
// 页眉
"profile" => "个人资料",
"dashboard" => "仪表盘",
"subscriptions" => "订阅",
"stats" => "统计",
"settings" => "设置",
@@ -43,6 +44,19 @@ $i18n = [
"about" => "关于",
"logout" => "登出",
// Dashboard
"hello" => "你好",
"upcoming_payments" => "即将到期的付款",
"no_upcoming_payments" => "您没有任何即将到期的付款",
"overdue_renewals" => "逾期续订",
"ai_recommendations" => "AI 推荐",
"your_budget" => "您的预算",
"budget" => "预算",
"budget_used" => "预算已使用",
"over_budget" => "超出预算",
"your_subscriptions" => "您的订阅",
"your_savings" => "您的储蓄",
// 订阅页面
"subscription" => "订阅",
"no_subscriptions_yet" => "您还没有任何订阅",
@@ -224,6 +238,16 @@ $i18n = [
"get_key" => "申请密钥",
"get_free_fixer_api_key" => "申请免费 Fixer API 密钥",
"get_key_alternative" => "或者,您也可以从以下网站获取免费的修复程序 api 密钥",
"ai_model" => "AI 模型",
"select_ai_model" => "选择 AI 模型",
"run_schedule" => "运行计划",
"manually" => "手动",
"coming_soon" => "即将推出",
"invalid_host" => "无效的主机",
"ai_recommendations_info" => "AI 推荐是基于您的订阅和家庭成员生成的。",
"may_take_time" => "根据提供商、模型和订阅数量,推荐生成可能需要一些时间。",
"recommendations_visible_on_dashboard" => "推荐将在仪表板上可见。",
"generate_recommendations" => "生成推荐",
"display_settings" => "显示设置",
"theme_settings" => "主题设置",
"colors" => "颜色",
@@ -369,6 +393,7 @@ $i18n = [
"oidc_settings" => "OIDC 设置",
"oidc_auth_enabled" => "启用 OIDC 身份验证",
"create_user_automatically" => "当使用 OIDC 登录时自动创建用户",
"disable_password_login" => "禁用密码登录",
"smtp_settings" => "SMTP 设置",
"smtp_usage_info" => "将用于密码恢复和其他系统电子邮件。",
"maintenance_tasks" => "维护任务",

View File

@@ -33,12 +33,25 @@ $i18n = [
"password_reset_successful" => "密碼重設成功",
// 頁首
"profile" => "個人檔案",
"dashboard" => "儀表板",
"subscriptions" => "訂閱服務",
"stats" => "統計資訊",
"settings" => "設定",
"admin" => "管理員",
"about" => "關於",
"logout" => "登出",
// Dashboard
"hello" => "你好",
"upcoming_payments" => "即將到期的付款",
"no_upcoming_payments" => "您沒有任何即將到期的付款",
"overdue_renewals" => "逾期續訂",
"ai_recommendations" => "AI 推荐",
"your_budget" => "您的預算",
"budget" => "預算",
"budget_used" => "預算已使用",
"over_budget" => "超出預算",
"your_subscriptions" => "您的訂閱",
"your_savings" => "您的儲蓄",
// 訂閱頁面
"subscription" => "訂閱服務",
"no_subscriptions_yet" => "您目前沒有任何訂閱服務",
@@ -217,7 +230,17 @@ $i18n = [
"get_key" => "取得金鑰請至",
"get_free_fixer_api_key" => "取得免費 Fixer API 金鑰",
"get_key_alternative" => "或者,您可以從以下網址取得免費的 Fixer API 金鑰",
"display_settings" => "顯示設定",
"ai_model" => "AI 模型",
"select_ai_model" => "選擇 AI 模型",
"run_schedule" => "運行計劃",
"manually" => "手動",
"coming_soon" => "即將推出",
"invalid_host" => "無效的主機",
"ai_recommendations_info" => "AI 推荐是基于您的订阅和家庭成员生成的。",
"may_take_time" => "根据提供商、模型和订阅数量,推荐生成可能需要一些时间。",
"recommendations_visible_on_dashboard" => "推荐将在仪表板上可见。",
"generate_recommendations" => "生成推荐",
"display_settings" => "显示设置",
"theme_settings" => "主題設定",
"colors" => "顏色",
"custom_colors" => "自訂顏色",
@@ -352,6 +375,7 @@ $i18n = [
"oidc_settings" => "OIDC 設定",
"oidc_auth_enabled" => "啟用 OIDC 身份驗證",
"create_user_automatically" => "當使用 OIDC 登入時自動建立使用者",
"disable_password_login" => "停用密碼登入",
"smtp_settings" => "SMTP 設定",
"smtp_usage_info" => "用於密碼重設和其他系統郵件。",
"maintenance_tasks" => "維護工作",

View File

@@ -0,0 +1,282 @@
<?php
function getPricePerMonth($cycle, $frequency, $price)
{
switch ($cycle) {
case 1:
$numberOfPaymentsPerMonth = (30 / $frequency);
return $price * $numberOfPaymentsPerMonth;
case 2:
$numberOfPaymentsPerMonth = (4.35 / $frequency);
return $price * $numberOfPaymentsPerMonth;
case 3:
$numberOfPaymentsPerMonth = (1 / $frequency);
return $price * $numberOfPaymentsPerMonth;
case 4:
$numberOfMonths = (12 * $frequency);
return $price / $numberOfMonths;
}
}
function getPriceConverted($price, $currency, $database, $userId)
{
$query = "SELECT rate FROM currencies WHERE id = :currency AND user_id = :userId";
$stmt = $database->prepare($query);
$stmt->bindParam(':currency', $currency, SQLITE3_INTEGER);
$stmt->bindParam(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$exchangeRate = $result->fetchArray(SQLITE3_ASSOC);
if ($exchangeRate === false) {
return $price;
} else {
$fromRate = $exchangeRate['rate'];
return $price / $fromRate;
}
}
// Get categories
$categories = array();
$query = "SELECT * FROM categories WHERE user_id = :userId ORDER BY 'order' ASC";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$categoryId = $row['id'];
$categories[$categoryId] = $row;
$categories[$categoryId]['count'] = 0;
$categoryCost[$categoryId]['cost'] = 0;
$categoryCost[$categoryId]['name'] = $row['name'];
}
// Get payment methods
$paymentMethods = array();
$query = "SELECT * FROM payment_methods WHERE user_id = :userId AND enabled = 1";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$paymentMethodId = $row['id'];
$paymentMethods[$paymentMethodId] = $row;
$paymentMethods[$paymentMethodId]['count'] = 0;
$paymentMethodsCount[$paymentMethodId]['count'] = 0;
$paymentMethodsCount[$paymentMethodId]['name'] = $row['name'];
}
//Get household members
$members = array();
$query = "SELECT * FROM household WHERE user_id = :userId";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$memberId = $row['id'];
$members[$memberId] = $row;
$members[$memberId]['count'] = 0;
$memberCost[$memberId]['cost'] = 0;
$memberCost[$memberId]['name'] = $row['name'];
}
$activeSubscriptions = 0;
$inactiveSubscriptions = 0;
// Calculate total monthly price
$mostExpensiveSubscription = array();
$mostExpensiveSubscription['price'] = 0;
$amountDueThisMonth = 0;
$totalCostPerMonth = 0;
$totalSavingsPerMonth = 0;
$totalCostsInReplacementsPerMonth = 0;
$statsSubtitleParts = [];
$query = "SELECT name, price, logo, frequency, cycle, currency_id, next_payment, payer_user_id, category_id, payment_method_id, inactive, replacement_subscription_id FROM subscriptions";
$conditions = [];
$params = [];
if (isset($_GET['member'])) {
$conditions[] = "payer_user_id = :member";
$params[':member'] = $_GET['member'];
$statsSubtitleParts[] = $members[$_GET['member']]['name'];
}
if (isset($_GET['category'])) {
$conditions[] = "category_id = :category";
$params[':category'] = $_GET['category'];
$statsSubtitleParts[] = $categories[$_GET['category']]['name'] == "No category" ? translate("no_category", $i18n) : $categories[$_GET['category']]['name'];
}
if (isset($_GET['payment'])) {
$conditions[] = "payment_method_id = :payment";
$params[':payment'] = $_GET['payment'];
$statsSubtitleParts[] = $paymentMethodsCount[$_GET['payment']]['name'];
}
$conditions[] = "user_id = :userId";
$params[':userId'] = $userId;
if (!empty($conditions)) {
$query .= " WHERE " . implode(' AND ', $conditions);
}
$stmt = $db->prepare($query);
$statsSubtitle = !empty($statsSubtitleParts) ? '(' . implode(', ', $statsSubtitleParts) . ')' : "";
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, SQLITE3_INTEGER);
}
$result = $stmt->execute();
$usesMultipleCurrencies = false;
if ($result) {
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$subscriptions[] = $row;
}
if (isset($subscriptions)) {
$replacementSubscriptions = array();
foreach ($subscriptions as $subscription) {
$name = $subscription['name'];
$price = $subscription['price'];
$logo = $subscription['logo'];
$frequency = $subscription['frequency'];
$cycle = $subscription['cycle'];
$currency = $subscription['currency_id'];
if ($currency != $userData['main_currency']) {
$usesMultipleCurrencies = true;
}
$next_payment = $subscription['next_payment'];
$payerId = $subscription['payer_user_id'];
$members[$payerId]['count'] += 1;
$categoryId = $subscription['category_id'];
$categories[$categoryId]['count'] += 1;
$paymentMethodId = $subscription['payment_method_id'];
$paymentMethods[$paymentMethodId]['count'] += 1;
$inactive = $subscription['inactive'];
$replacementSubscriptionId = $subscription['replacement_subscription_id'];
$originalSubscriptionPrice = getPriceConverted($price, $currency, $db, $userId);
$price = getPricePerMonth($cycle, $frequency, $originalSubscriptionPrice);
if ($inactive == 0) {
$activeSubscriptions++;
$totalCostPerMonth += $price;
$memberCost[$payerId]['cost'] += $price;
$categoryCost[$categoryId]['cost'] += $price;
$paymentMethodsCount[$paymentMethodId]['count'] += 1;
if ($price > $mostExpensiveSubscription['price']) {
$mostExpensiveSubscription['price'] = $price;
$mostExpensiveSubscription['name'] = $name;
$mostExpensiveSubscription['logo'] = $logo;
}
// Calculate ammount due this month
$nextPaymentDate = DateTime::createFromFormat('Y-m-d', trim($next_payment));
$tomorrow = new DateTime('tomorrow');
$endOfMonth = new DateTime('last day of this month');
if ($nextPaymentDate >= $tomorrow && $nextPaymentDate <= $endOfMonth) {
$timesToPay = 1;
$daysInMonth = $endOfMonth->diff($tomorrow)->days + 1;
$daysRemaining = $endOfMonth->diff($nextPaymentDate)->days + 1;
if ($cycle == 1) {
$timesToPay = $daysRemaining / $frequency;
}
if ($cycle == 2) {
$weeksInMonth = ceil($daysInMonth / 7);
$weeksRemaining = ceil($daysRemaining / 7);
$timesToPay = $weeksRemaining / $frequency;
}
$amountDueThisMonth += $originalSubscriptionPrice * $timesToPay;
}
} else {
$inactiveSubscriptions++;
$totalSavingsPerMonth += $price;
// Check if it has a replacement subscription and if it was not already counted
if ($replacementSubscriptionId && !in_array($replacementSubscriptionId, $replacementSubscriptions)) {
$query = "SELECT price, currency_id, cycle, frequency FROM subscriptions WHERE id = :replacementSubscriptionId";
$stmt = $db->prepare($query);
$stmt->bindValue(':replacementSubscriptionId', $replacementSubscriptionId, SQLITE3_INTEGER);
$result = $stmt->execute();
$replacementSubscription = $result->fetchArray(SQLITE3_ASSOC);
if ($replacementSubscription) {
$replacementSubscriptionPrice = getPriceConverted($replacementSubscription['price'], $replacementSubscription['currency_id'], $db, $userId);
$replacementSubscriptionPrice = getPricePerMonth($replacementSubscription['cycle'], $replacementSubscription['frequency'], $replacementSubscriptionPrice);
$totalCostsInReplacementsPerMonth += $replacementSubscriptionPrice;
}
}
$replacementSubscriptions[] = $replacementSubscriptionId;
}
}
// Subtract the total cost of replacement subscriptions from the total savings
$totalSavingsPerMonth -= $totalCostsInReplacementsPerMonth;
// Calculate yearly price
$totalCostPerYear = $totalCostPerMonth * 12;
// Calculate average subscription monthly cost
if ($activeSubscriptions > 0) {
$averageSubscriptionCost = $totalCostPerMonth / $activeSubscriptions;
} else {
$totalCostPerYear = 0;
$averageSubscriptionCost = 0;
}
} else {
$totalCostPerYear = 0;
$averageSubscriptionCost = 0;
}
}
$showVsBudgetGraph = false;
$vsBudgetDataPoints = [];
if (isset($userData['budget']) && $userData['budget'] > 0) {
$budget = $userData['budget'];
$budgetLeft = $budget - $totalCostPerMonth;
$budgetLeft = $budgetLeft < 0 ? 0 : $budgetLeft;
$budgetUsed = ($totalCostPerMonth / $budget) * 100;
$budgetUsed = $budgetUsed > 100 ? 100 : $budgetUsed;
if ($totalCostPerMonth > $budget) {
$overBudgetAmount = $totalCostPerMonth - $budget;
}
$showVsBudgetGraph = true;
$vsBudgetDataPoints = [
[
"label" => translate('budget_remaining', $i18n),
"y" => $budgetLeft,
],
[
"label" => translate('total_cost', $i18n),
"y" => $totalCostPerMonth,
],
];
}
$showCantConverErrorMessage = false;
if ($usesMultipleCurrencies) {
$query = "SELECT api_key FROM fixer WHERE user_id = :userId";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
if ($result->fetchArray(SQLITE3_ASSOC) === false) {
$showCantConverErrorMessage = true;
}
}
$query = "SELECT * FROM total_yearly_cost WHERE user_id = :userId";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$totalMonthlyCostDataPoints = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$totalMonthlyCostDataPoints[] = [
"label" => html_entity_decode($row['date']),
"y" => round($row['cost'] / 12, 2),
];
}
$showTotalMonthlyCostGraph = count($totalMonthlyCostDataPoints) > 1;
?>

View File

@@ -1,3 +1,3 @@
<?php
$version = "v4.0.0";
$version = "v4.1.0";
?>

873
index.php
View File

@@ -3,546 +3,371 @@
require_once 'includes/header.php';
require_once 'includes/getdbkeys.php';
include_once 'includes/list_subscriptions.php';
$sort = "next_payment";
$sortOrder = $sort;
if ($settings['disabledToBottom'] === 'true') {
$sql = "SELECT * FROM subscriptions WHERE user_id = :userId ORDER BY inactive ASC, next_payment ASC";
} else {
$sql = "SELECT * FROM subscriptions WHERE user_id = :userId ORDER BY next_payment ASC, inactive ASC";
}
$params = array();
if (isset($_COOKIE['sortOrder']) && $_COOKIE['sortOrder'] != "") {
$sort = $_COOKIE['sortOrder'] ?? 'next_payment';
}
$sortOrder = $sort;
$allowedSortCriteria = ['name', 'id', 'next_payment', 'price', 'payer_user_id', 'category_id', 'payment_method_id', 'inactive', 'alphanumeric', 'renewal_type'];
$order = ($sort == "price" || $sort == "id") ? "DESC" : "ASC";
if ($sort == "alphanumeric") {
$sort = "name";
}
if (!in_array($sort, $allowedSortCriteria)) {
$sort = "next_payment";
}
if ($sort == "renewal_type") {
$sort = "auto_renew";
}
$sql = "SELECT * FROM subscriptions WHERE user_id = :userId";
if (isset($_GET['member'])) {
$memberIds = explode(',', $_GET['member']);
$placeholders = array_map(function ($key) {
return ":member{$key}";
}, array_keys($memberIds));
$sql .= " AND payer_user_id IN (" . implode(',', $placeholders) . ")";
foreach ($memberIds as $key => $memberId) {
$params[":member{$key}"] = $memberId;
}
}
if (isset($_GET['category'])) {
$categoryIds = explode(',', $_GET['category']);
$placeholders = array_map(function ($key) {
return ":category{$key}";
}, array_keys($categoryIds));
$sql .= " AND category_id IN (" . implode(',', $placeholders) . ")";
foreach ($categoryIds as $key => $categoryId) {
$params[":category{$key}"] = $categoryId;
}
}
if (isset($_GET['payment'])) {
$paymentIds = explode(',', $_GET['payment']);
$placeholders = array_map(function ($key) {
return ":payment{$key}";
}, array_keys($paymentIds));
$sql .= " AND payment_method_id IN (" . implode(',', $placeholders) . ")";
foreach ($paymentIds as $key => $paymentId) {
$params[":payment{$key}"] = $paymentId;
}
}
if (!isset($settings['hideDisabledSubscriptions']) || $settings['hideDisabledSubscriptions'] !== 'true') {
if (isset($_GET['state']) && $_GET['state'] != "") {
$sql .= " AND inactive = :inactive";
$params[':inactive'] = $_GET['state'];
}
}
$orderByClauses = [];
if ($settings['disabledToBottom'] === 'true') {
if (in_array($sort, ["payer_user_id", "category_id", "payment_method_id"])) {
$orderByClauses[] = "$sort $order";
$orderByClauses[] = "inactive ASC";
} else {
$orderByClauses[] = "inactive ASC";
$orderByClauses[] = "$sort $order";
}
} else {
$orderByClauses[] = "$sort $order";
if ($sort != "inactive") {
$orderByClauses[] = "inactive ASC";
}
}
if ($sort != "next_payment") {
$orderByClauses[] = "next_payment ASC";
}
$sql .= " ORDER BY " . implode(", ", $orderByClauses);
$stmt = $db->prepare($sql);
// Redirect to subscriptions page if no subscriptions exist
$stmt = $db->prepare("SELECT COUNT(*) FROM subscriptions WHERE user_id = :userId");
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
if (!empty($params)) {
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, SQLITE3_INTEGER);
}
}
$result = $stmt->execute();
if ($result) {
$subscriptions = array();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$subscriptions[] = $row;
}
$row = $result->fetchArray(SQLITE3_NUM);
$subscriptionCount = $row[0];
if ($subscriptionCount === 0) {
header('Location: subscriptions.php');
exit;
}
foreach ($subscriptions as $subscription) {
$memberId = $subscription['payer_user_id'];
$members[$memberId]['count']++;
$categoryId = $subscription['category_id'];
$categories[$categoryId]['count']++;
$paymentMethodId = $subscription['payment_method_id'];
$payment_methods[$paymentMethodId]['count']++;
}
function formatPrice($price, $currencyCode, $currencies)
{
$formattedPrice = CurrencyFormatter::format($price, $currencyCode);
if (strstr($formattedPrice, $currencyCode)) {
$symbol = $currencyCode;
if ($sortOrder == "category_id") {
usort($subscriptions, function ($a, $b) use ($categories) {
return $categories[$a['category_id']]['order'] - $categories[$b['category_id']]['order'];
});
}
foreach ($currencies as $currency) {
if ($sortOrder == "payment_method_id") {
usort($subscriptions, function ($a, $b) use ($payment_methods) {
return $payment_methods[$a['payment_method_id']]['order'] - $payment_methods[$b['payment_method_id']]['order'];
});
}
$headerClass = count($subscriptions) > 0 ? "main-actions" : "main-actions hidden";
?>
<style>
.logo-preview:after {
content: '<?= translate('upload_logo', $i18n) ?>';
}
</style>
<section class="contain">
<?php
if ($isAdmin && $settings['update_notification']) {
if (!is_null($settings['latest_version'])) {
$latestVersion = $settings['latest_version'];
if (version_compare($version, $latestVersion) == -1) {
?>
<div class="update-banner">
<?= translate('new_version_available', $i18n) ?>:
<span><a href="https://github.com/ellite/Wallos/releases/tag/<?= htmlspecialchars($latestVersion) ?>"
target="_blank" rel="noreferer">
<?= htmlspecialchars($latestVersion) ?>
</a></span>
</div>
<?php
}
if ($currency['code'] === $currencyCode) {
if ($currency['symbol'] != "") {
$symbol = $currency['symbol'];
}
break;
}
}
$formattedPrice = str_replace($currencyCode, $symbol, $formattedPrice);
}
}
if ($demoMode) {
?>
<div class="demo-banner">
Running in <b>Demo Mode</b>, certain actions and settings are disabled.<br>
The database will be reset every 120 minutes.
</div>
<?php
}
?>
return $formattedPrice;
}
<header class="<?= $headerClass ?>" id="main-actions">
<button class="button" onClick="addSubscription()">
<i class="fa-solid fa-circle-plus"></i>
<?= translate('new_subscription', $i18n) ?>
</button>
<div class="top-actions">
<div class="search">
<input type="text" autocomplete="off" name="search" id="search" placeholder="<?= translate('search', $i18n) ?>"
onkeyup="searchSubscriptions()" />
<span class="fa-solid fa-magnifying-glass search-icon"></span>
<span class="fa-solid fa-xmark clear-search" onClick="clearSearch()"></span>
</div>
function formatDate($date, $lang = 'en')
{
$currentYear = date('Y');
$dateYear = date('Y', strtotime($date));
<div class="filtermenu on-dashboard">
<button class="button secondary-button" id="filtermenu-button" title="<?= translate("filter", $i18n) ?>">
<i class="fa-solid fa-filter"></i>
</button>
<?php include 'includes/filters_menu.php'; ?>
</div>
// Determine the date format based on whether the year matches the current year
$dateFormat = ($currentYear == $dateYear) ? 'MMM d' : 'MMM yyyy';
<div class="sort-container">
<button class="button secondary-button" value="Sort" onClick="toggleSortOptions()" id="sort-button"
title="<?= translate('sort', $i18n) ?>">
<i class="fa-solid fa-arrow-down-wide-short"></i>
</button>
<?php include 'includes/sort_options.php'; ?>
</div>
</div>
</header>
<div class="subscriptions" id="subscriptions">
<?php
// Validate the locale and fallback to 'en' if unsupported
if (!in_array($lang, ResourceBundle::getLocales(''))) {
$lang = 'en'; // Fallback to English
}
// Create an IntlDateFormatter instance for the specified language
$formatter = new IntlDateFormatter(
'en', // Force English locale
IntlDateFormatter::SHORT,
IntlDateFormatter::NONE,
null,
null,
'MMM d, yyyy'
$lang,
IntlDateFormatter::SHORT,
IntlDateFormatter::NONE,
null,
null,
$dateFormat
);
foreach ($subscriptions as $subscription) {
if ($subscription['inactive'] == 1 && isset($settings['hideDisabledSubscriptions']) && $settings['hideDisabledSubscriptions'] === 'true') {
continue;
}
$id = $subscription['id'];
$print[$id]['id'] = $id;
$print[$id]['logo'] = $subscription['logo'] != "" ? "images/uploads/logos/" . $subscription['logo'] : "";
$print[$id]['name'] = $subscription['name'];
$cycle = $subscription['cycle'];
$frequency = $subscription['frequency'];
$print[$id]['billing_cycle'] = getBillingCycle($cycle, $frequency, $i18n);
$paymentMethodId = $subscription['payment_method_id'];
$print[$id]['currency_code'] = $currencies[$subscription['currency_id']]['code'];
$currencyId = $subscription['currency_id'];
$print[$id]['auto_renew'] = $subscription['auto_renew'];
$next_payment_timestamp = strtotime($subscription['next_payment']);
$formatted_date = $formatter->format($next_payment_timestamp);
$print[$id]['next_payment'] = $formatted_date;
$paymentIconFolder = (strpos($payment_methods[$paymentMethodId]['icon'], 'images/uploads/icons/') !== false) ? "" : "images/uploads/logos/";
$print[$id]['payment_method_icon'] = $paymentIconFolder . $payment_methods[$paymentMethodId]['icon'];
$print[$id]['payment_method_name'] = $payment_methods[$paymentMethodId]['name'];
$print[$id]['payment_method_id'] = $paymentMethodId;
$print[$id]['category_id'] = $subscription['category_id'];
$print[$id]['payer_user_id'] = $subscription['payer_user_id'];
$print[$id]['price'] = floatval($subscription['price']);
$print[$id]['progress'] = getSubscriptionProgress($cycle, $frequency, $subscription['next_payment']);
$print[$id]['inactive'] = $subscription['inactive'];
$print[$id]['url'] = $subscription['url'];
$print[$id]['notes'] = $subscription['notes'];
$print[$id]['replacement_subscription_id'] = $subscription['replacement_subscription_id'];
// Format the date
$formattedDate = $formatter->format(new DateTime($date));
if (isset($settings['convertCurrency']) && $settings['convertCurrency'] === 'true' && $currencyId != $mainCurrencyId) {
$print[$id]['price'] = getPriceConverted($print[$id]['price'], $currencyId, $db);
$print[$id]['currency_code'] = $currencies[$mainCurrencyId]['code'];
}
if (isset($settings['showMonthlyPrice']) && $settings['showMonthlyPrice'] === 'true') {
$print[$id]['price'] = getPricePerMonth($cycle, $frequency, $print[$id]['price']);
}
if (isset($settings['showOriginalPrice']) && $settings['showOriginalPrice'] === 'true') {
$print[$id]['original_price'] = floatval($subscription['price']);
$print[$id]['original_currency_code'] = $currencies[$subscription['currency_id']]['code'];
}
}
if ($sortOrder == "alphanumeric") {
usort($print, function ($a, $b) {
return strnatcmp(strtolower($a['name']), strtolower($b['name']));
});
if ($settings['disabledToBottom'] === 'true') {
usort($print, function ($a, $b) {
return $a['inactive'] - $b['inactive'];
});
}
}
if (isset($print)) {
printSubscriptions($print, $sort, $categories, $members, $i18n, $colorTheme, "", $settings['disabledToBottom'], $settings['mobileNavigation'], $settings['showSubscriptionProgress'], $currencies, $lang);
}
$db->close();
if (count($subscriptions) == 0) {
?>
<div class="empty-page">
<img src="images/siteimages/empty.png" alt="<?= translate('empty_page', $i18n) ?>" />
<p>
<?= translate('no_subscriptions_yet', $i18n) ?>
</p>
<button class="button" onClick="addSubscription()">
<i class="fa-solid fa-circle-plus"></i>
<?= translate('add_first_subscription', $i18n) ?>
</button>
</div>
<?php
}
?>
</div>
</section>
<section class="subscription-form" id="subscription-form">
<header>
<h3 id="form-title"><?= translate('add_subscription', $i18n) ?></h3>
<span class="fa-solid fa-xmark close-form" onClick="closeAddSubscription()"></span>
</header>
<form action="endpoints/subscription/add.php" method="post" id="subs-form">
<div class="form-group-inline">
<input type="text" id="name" name="name" placeholder="<?= translate('subscription_name', $i18n) ?>"
onchange="setSearchButtonStatus()" onkeypress="this.onchange();" onpaste="this.onchange();"
oninput="this.onchange();" required>
<label for="logo" class="logo-preview">
<img src="" alt="<?= translate('logo_preview', $i18n) ?>" id="form-logo">
</label>
<input type="file" id="logo" name="logo" accept="image/jpeg, image/png, image/gif, image/webp, image/svg+xml"
onchange="handleFileSelect(event)" class="hidden-input">
<input type="hidden" id="logo-url" name="logo-url">
<div id="logo-search-button" class="image-button medium disabled" title="<?= translate('search_logo', $i18n) ?>"
onClick="searchLogo()">
<?php include "images/siteicons/svg/websearch.php"; ?>
</div>
<input type="hidden" id="id" name="id">
<div id="logo-search-results" class="logo-search">
<header>
<?= translate('web_search', $i18n) ?>
<span class="fa-solid fa-xmark close-logo-search" onClick="closeLogoSearch()"></span>
</header>
<div id="logo-search-images"></div>
</div>
</div>
<div class="form-group-inline">
<input type="number" step="0.01" id="price" name="price" placeholder="<?= translate('price', $i18n) ?>" required>
<select id="currency" name="currency_id" placeholder="<?= translate('add_subscription', $i18n) ?>">
<?php
foreach ($currencies as $currency) {
$selected = ($currency['id'] == $main_currency) ? 'selected' : '';
?>
<option value="<?= $currency['id'] ?>" <?= $selected ?>><?= $currency['name'] ?></option>
<?php
}
?>
</select>
</div>
<div class="form-group">
<div class="inline">
<div class="split66">
<label for="cycle"><?= translate('payment_every', $i18n) ?></label>
<div class="inline">
<select id="frequency" name="frequency" placeholder="<?= translate('frequency', $i18n) ?>">
<?php
for ($i = 1; $i <= 366; $i++) {
?>
<option value="<?= $i ?>"><?= $i ?></option>
<?php
}
?>
</select>
<select id="cycle" name="cycle" placeholder="Cycle">
<?php
foreach ($cycles as $cycle) {
?>
<option value="<?= $cycle['id'] ?>" <?= $cycle['id'] == 3 ? "selected" : "" ?>>
<?= translate(strtolower($cycle['name']), $i18n) ?>
</option>
<?php
}
?>
</select>
</div>
</div>
<div class="split33">
<label><?= translate('auto_renewal', $i18n) ?></label>
<div class="inline height50">
<input type="checkbox" id="auto_renew" name="auto_renew" checked>
<label for="auto_renew"><?= translate('automatically_renews', $i18n) ?></label>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="inline">
<div class="split50">
<label for="start_date"><?= translate('start_date', $i18n) ?></label>
<div class="date-wrapper">
<input type="date" id="start_date" name="start_date">
</div>
</div>
<button type="button" id="autofill-next-payment-button"
class="button secondary-button autofill-next-payment hideOnMobile"
title="<?= translate('calculate_next_payment_date', $i18n) ?>" onClick="autoFillNextPaymentDate(event)">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</button>
<div class="split50">
<label for="next_payment" class="split-label">
<?= translate('next_payment', $i18n) ?>
<div id="autofill-next-payment-button" class="autofill-next-payment hideOnDesktop"
title="<?= translate('calculate_next_payment_date', $i18n) ?>" onClick="autoFillNextPaymentDate(event)">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</div>
</label>
<div class="date-wrapper">
<input type="date" id="next_payment" name="next_payment" required>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="inline">
<div class="split50">
<label for="payment_method"><?= translate('payment_method', $i18n) ?></label>
<select id="payment_method" name="payment_method_id">
<?php
foreach ($payment_methods as $payment) {
?>
<option value="<?= $payment['id'] ?>">
<?= $payment['name'] ?>
</option>
<?php
}
?>
</select>
</div>
<div class="split50">
<label for="payer_user"><?= translate('paid_by', $i18n) ?></label>
<select id="payer_user" name="payer_user_id">
<?php
foreach ($members as $member) {
?>
<option value="<?= $member['id'] ?>"><?= $member['name'] ?></option>
<?php
}
?>
</select>
</div>
</div>
</div>
<div class="form-group">
<label for="category"><?= translate('category', $i18n) ?></label>
<select id="category" name="category_id">
<?php
foreach ($categories as $category) {
?>
<option value="<?= $category['id'] ?>">
<?= $category['name'] ?>
</option>
<?php
}
?>
</select>
</div>
<div class="form-group-inline grow">
<input type="checkbox" id="notifications" name="notifications" onchange="toggleNotificationDays()">
<label for="notifications" class="grow"><?= translate('enable_notifications', $i18n) ?></label>
</div>
<div class="form-group">
<div class="inline">
<div class="split66 mobile-split-50">
<label for="notify_days_before"><?= translate('notify_me', $i18n) ?></label>
<select id="notify_days_before" name="notify_days_before" disabled>
<option value="-1"><?= translate('default_value_from_settings', $i18n) ?></option>
<option value="0"><?= translate('on_due_date', $i18n) ?></option>
<option value="1">1 <?= translate('day_before', $i18n) ?></option>
<?php
for ($i = 2; $i <= 90; $i++) {
?>
<option value="<?= $i ?>"><?= $i ?> <?= translate('days_before', $i18n) ?></option>
<?php
}
?>
</select>
</div>
<div class="split33 mobile-split-50">
<label for="cancellation_date"><?= translate('cancellation_notification', $i18n) ?></label>
<div class="date-wrapper">
<input type="date" id="cancellation_date" name="cancellation_date">
</div>
</div>
</div>
</div>
<div class="form-group">
<input type="text" id="url" name="url" placeholder="<?= translate('url', $i18n) ?>">
</div>
<div class="form-group">
<input type="text" id="notes" name="notes" placeholder="<?= translate('notes', $i18n) ?>">
</div>
<div class="form-group">
<div class="inline grow">
<input type="checkbox" id="inactive" name="inactive" onchange="toggleReplacementSub()">
<label for="inactive" class="grow"><?= translate('inactive', $i18n) ?></label>
</div>
</div>
<?php
$orderedSubscriptions = $subscriptions;
usort($orderedSubscriptions, function ($a, $b) {
return strnatcmp(strtolower($a['name']), strtolower($b['name']));
});
?>
<div class="form-group hide" id="replacement_subscritpion">
<label for="replacement_subscription_id"><?= translate('replaced_with', $i18n) ?>:</label>
<select id="replacement_subscription_id" name="replacement_subscription_id">
<option value="0"><?= translate('none', $i18n) ?></option>
<?php
foreach ($orderedSubscriptions as $sub) {
if ($sub['inactive'] == 0) {
?>
<option value="<?= htmlspecialchars($sub['id']) ?>"><?= htmlspecialchars($sub['name']) ?>
</option>
<?php
}
}
?>
</select>
</div>
<div class="buttons">
<input type="button" value="<?= translate('delete', $i18n) ?>" class="warning-button left thin" id="deletesub"
style="display: none">
<input type="button" value="<?= translate('cancel', $i18n) ?>" class="secondary-button thin"
onClick="closeAddSubscription()">
<input type="submit" value="<?= translate('save', $i18n) ?>" class="thin" id="save-button">
</div>
</form>
</section>
<script src="scripts/dashboard.js?<?= $version ?>"></script>
<?php
if (isset($_GET['add'])) {
?>
<script>
addSubscription();
</script>
<?php
return $formattedDate;
}
// Get the first name of the user
$stmt = $db->prepare("SELECT username, firstname FROM user WHERE id = :userId");
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$user = $result->fetchArray(SQLITE3_ASSOC);
$first_name = $user['firstname'] ?? $user['username'] ?? '';
// Fetch the next 3 enabled subscriptions up for payment
$stmt = $db->prepare("SELECT id, logo, name, price, currency_id, next_payment, inactive FROM subscriptions WHERE user_id = :userId AND next_payment >= date('now') AND inactive = 0 ORDER BY next_payment ASC LIMIT 3");
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$upcomingSubscriptions = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$upcomingSubscriptions[] = $row;
}
// Fetch enabled subscriptions with manual renewal that are overdue
$stmt = $db->prepare("SELECT id, logo, name, price, currency_id, next_payment, inactive, auto_renew FROM subscriptions WHERE user_id = :userId AND next_payment < date('now') AND auto_renew = 0 AND inactive = 0 ORDER BY next_payment ASC");
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$overdueSubscriptions = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$overdueSubscriptions[] = $row;
}
$hasOverdueSubscriptions = !empty($overdueSubscriptions);
require_once 'includes/stats_calculations.php';
// Get AI Recommendations for user
$stmt = $db->prepare("SELECT * FROM ai_recommendations WHERE user_id = :userId");
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$aiRecommendations = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$aiRecommendations[] = $row;
}
?>
<section class="contain dashboard">
<h1><?= translate('hello', $i18n) ?> <?= htmlspecialchars($first_name) ?></h1>
<?php
// If there are overdue subscriptions, display them
if ($hasOverdueSubscriptions) {
?>
<div class="overdue-subscriptions">
<h2><?= translate('overdue_renewals', $i18n) ?></h2>
<div class="dashboard-subscriptions-container">
<div class="dashboard-subscriptions-list">
<?php
foreach ($overdueSubscriptions as $subscription) {
$subscriptionLogo = "images/uploads/logos/" . $subscription['logo'];
$subscriptionName = htmlspecialchars($subscription['name']);
$subscriptionPrice = $subscription['price'];
$subscriptionCurrency = $subscription['currency_id'];
$subscriptionNextPayment = $subscription['next_payment'];
$subscriptionDisplayNextPayment = date('F j', strtotime($subscriptionNextPayment));
$subscriptionDisplayPrice = formatPrice($subscriptionPrice, $currencies[$subscriptionCurrency]['code'], $currencies);
?>
<div class="subscription-item">
<?php
if (empty($subscription['logo'])) {
?>
<p class="subscription-item-title"><?= $subscriptionName ?></p>
<?php
} else {
?>
<img src="<?= $subscriptionLogo ?>" alt="<?= $subscriptionName ?> logo"
class="subscription-item-logo" title="<?= $subscriptionName ?>">
<?php
}
?>
<div class="subscription-item-info">
<p class="subscription-item-date"> <?= formatDate($subscriptionDisplayNextPayment, $lang) ?>
</p>
<p class="subscription-item-price"> <?= $subscriptionDisplayPrice ?></p>
</div>
</div>
<?php
}
?>
</div>
</div>
</div>
<?php
}
?>
<div class="upcoming-subscriptions">
<h2><?= translate('upcoming_payments', $i18n) ?></h2>
<div class="dashboard-subscriptions-container">
<div class="dashboard-subscriptions-list">
<?php
if (empty($upcomingSubscriptions)) {
?>
<p><?= translate('no_upcoming_payments', $i18n) ?></p>
<?php
} else {
foreach ($upcomingSubscriptions as $subscription) {
$subscriptionLogo = "images/uploads/logos/" . $subscription['logo'];
$subscriptionName = htmlspecialchars($subscription['name']);
$subscriptionPrice = $subscription['price'];
$subscriptionCurrency = $subscription['currency_id'];
$subscriptionNextPayment = $subscription['next_payment'];
$subscriptionDisplayNextPayment = date('F j', strtotime($subscriptionNextPayment));
$subscriptionDisplayPrice = formatPrice($subscriptionPrice, $currencies[$subscriptionCurrency]['code'], $currencies);
?>
<div class="subscription-item">
<?php
if (empty($subscription['logo'])) {
?>
<p class="subscription-item-title"><?= $subscriptionName ?></p>
<?php
} else {
?>
<img src="<?= $subscriptionLogo ?>" alt="<?= $subscriptionName ?> logo"
class="subscription-item-logo" title="<?= $subscriptionName ?>">
<?php
}
?>
<div class="subscription-item-info">
<p class="subscription-item-date"> <?= formatDate($subscriptionDisplayNextPayment, $lang) ?></p>
<p class="subscription-item-price"> <?= $subscriptionDisplayPrice ?></p>
</div>
</div>
<?php
}
}
?>
</div>
</div>
<?php if (!empty($aiRecommendations)) { ?>
<div class="ai-recommendations">
<h2><?= translate('ai_recommendations', $i18n) ?></h2>
<div class="ai-recommendations-container">
<ul class="ai-recommendations-list">
<?php
foreach ($aiRecommendations as $key => $recommendation) { ?>
<li class="ai-recommendation-item">
<div class="ai-recommendation-header">
<h3>
<span><?= ($key + 1) . ". " ?></span>
<?= htmlspecialchars($recommendation['title']) ?>
</h3>
<span class="item-arrow-down fa fa-caret-down"></span>
</div>
<p class="collapsible"><?= htmlspecialchars($recommendation['description']) ?></p>
<p><?= htmlspecialchars($recommendation['savings']) ?></p>
</li>
<?php } ?>
</ul>
</div>
</div>
<?php } ?>
<?php if (isset($amountDueThisMonth) || isset($budget) || isset($budgetUsed) || isset($budgetLeft) || isset($overBudgetAmount)) { ?>
<div class="budget-subscriptions">
<h2><?= translate('your_budget', $i18n) ?></h2>
<div class="dashboard-subscriptions-container">
<div class="dashboard-subscriptions-list">
<?php if (isset($amountDueThisMonth)) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate("amount_due", $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= CurrencyFormatter::format($amountDueThisMonth, $currencies[$userData['main_currency']]['code']) ?>
</p>
</div>
</div>
<?php } ?>
<?php if (isset($budget) && $budget > 0) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate("budget", $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= formatPrice($budget, $currencies[$userData['main_currency']]['code'], $currencies) ?>
</p>
</div>
</div>
<?php } ?>
<?php if (isset($budgetUsed)) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate("budget_used", $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= number_format($budgetUsed, 2) ?>%
</p>
</div>
</div>
<?php } ?>
<?php if (isset($budgetLeft)) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate("budget_remaining", $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= formatPrice($budgetLeft, $currencies[$userData['main_currency']]['code'], $currencies) ?>
</p>
</div>
</div>
<?php } ?>
<?php if (isset($overBudgetAmount) && $overBudgetAmount > 0) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate("over_budget", $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= formatPrice($overBudgetAmount, $currencies[$userData['main_currency']]['code'], $currencies) ?>
</p>
</div>
</div>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
</div>
<?php if (isset($activeSubscriptions) && $activeSubscriptions > 0) { ?>
<div class="current-subscriptions">
<h2><?= translate('your_subscriptions', $i18n) ?></h2>
<div class="dashboard-subscriptions-container">
<div class="dashboard-subscriptions-list">
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate('active_subscriptions', $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value"><?= $activeSubscriptions ?></p>
</div>
</div>
<?php if (isset($totalCostPerMonth)) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate('monthly_cost', $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= CurrencyFormatter::format($totalCostPerMonth, $currencies[$userData['main_currency']]['code']) ?>
</p>
</div>
</div>
<?php } ?>
<?php if (isset($totalCostPerYear)) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate('yearly_cost', $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= CurrencyFormatter::format($totalCostPerYear, $currencies[$userData['main_currency']]['code']) ?>
</p>
</div>
</div>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
<?php if (isset($inactiveSubscriptions) && $inactiveSubscriptions > 0) { ?>
<div class="savings-subscriptions">
<h2><?= translate('your_savings', $i18n) ?></h2>
<div class="dashboard-subscriptions-container">
<div class="dashboard-subscriptions-list">
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate('inactive_subscriptions', $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value"><?= $inactiveSubscriptions ?></p>
</div>
</div>
<?php if (isset($totalSavingsPerMonth) && $totalSavingsPerMonth > 0) { ?>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate('monthly_savings', $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= CurrencyFormatter::format($totalSavingsPerMonth, $currencies[$userData['main_currency']]['code']) ?>
</p>
</div>
</div>
<div class="subscription-item thin">
<p class="subscription-item-title"><?= translate('yearly_savings', $i18n) ?></p>
<div class="subscription-item-info">
<p class="subscription-item-value">
<?= CurrencyFormatter::format($totalSavingsPerMonth * 12, $currencies[$userData['main_currency']]['code']) ?>
</p>
</div>
</div>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
</section>
<script src="scripts/dashboard.js?<?= $version ?>"></script>
<?php
require_once 'includes/footer.php';
?>

View File

@@ -109,6 +109,7 @@ if (isset($_COOKIE['colorTheme'])) {
}
// Check if OIDC is Enabled
$password_login_disabled = false;
$oidcEnabled = false;
$oidcQuery = "SELECT oidc_oauth_enabled FROM admin";
$oidcResult = $db->query($oidcQuery);
@@ -124,6 +125,7 @@ if ($oidcRow) {
$oidcEnabled = false;
} else {
$oidc_name = $oidcSettings['name'] ?? '';
$password_login_disabled = $oidcSettings['password_login_disabled'] == 1;
// Generate a CSRF-protecting state string
if (session_status() === PHP_SESSION_NONE) {
@@ -249,30 +251,33 @@ if (isset($_POST['username']) && isset($_POST['password'])) {
//Check if registration is open
$registrations = false;
$adminQuery = "SELECT registrations_open, max_users, server_url, smtp_address FROM admin";
$adminResult = $db->query($adminQuery);
$adminRow = $adminResult->fetchArray(SQLITE3_ASSOC);
$registrationsOpen = $adminRow['registrations_open'];
$maxUsers = $adminRow['max_users'];
$resetPasswordEnabled = false;
if (!$password_login_disabled) {
$adminQuery = "SELECT registrations_open, max_users, server_url, smtp_address FROM admin";
$adminResult = $db->query($adminQuery);
$adminRow = $adminResult->fetchArray(SQLITE3_ASSOC);
$registrationsOpen = $adminRow['registrations_open'];
$maxUsers = $adminRow['max_users'];
if ($registrationsOpen == 1 && $maxUsers == 0) {
$registrations = true;
} else if ($registrationsOpen == 1 && $maxUsers > 0) {
$userCountQuery = "SELECT COUNT(id) as userCount FROM user";
$userCountResult = $db->query($userCountQuery);
$userCountRow = $userCountResult->fetchArray(SQLITE3_ASSOC);
$userCount = $userCountRow['userCount'];
if ($userCount < $maxUsers) {
if ($registrationsOpen == 1 && $maxUsers == 0) {
$registrations = true;
} else if ($registrationsOpen == 1 && $maxUsers > 0) {
$userCountQuery = "SELECT COUNT(id) as userCount FROM user";
$userCountResult = $db->query($userCountQuery);
$userCountRow = $userCountResult->fetchArray(SQLITE3_ASSOC);
$userCount = $userCountRow['userCount'];
if ($userCount < $maxUsers) {
$registrations = true;
}
}
if ($adminRow['smtp_address'] != "" && $adminRow['server_url'] != "") {
$resetPasswordEnabled = true;
}
}
$resetPasswordEnabled = false;
if ($adminRow['smtp_address'] != "" && $adminRow['server_url'] != "") {
$resetPasswordEnabled = true;
}
if(isset($_GET['error']) && $_GET['error'] == "oidc_user_not_found") {
if (isset($_GET['error']) && $_GET['error'] == "oidc_user_not_found") {
$loginFailed = true;
}
@@ -319,32 +324,40 @@ if(isset($_GET['error']) && $_GET['error'] == "oidc_user_not_found") {
</p>
</header>
<form action="login.php" method="post">
<div class="form-group">
<label for="username"><?= translate('username', $i18n) ?>:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password"><?= translate('password', $i18n) ?>:</label>
<input type="password" id="password" name="password" required>
</div>
<?php
if (!$demoMode) {
?>
<div class="form-group-inline">
<input type="checkbox" id="remember" name="remember">
<label for="remember"><?= translate('stay_logged_in', $i18n) ?></label>
<?php if (!$password_login_disabled) { ?>
<div class="form-group">
<label for="username"><?= translate('username', $i18n) ?>:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password"><?= translate('password', $i18n) ?>:</label>
<input type="password" id="password" name="password" required>
</div>
<?php
}
?>
if (!$demoMode) {
?>
<div class="form-group-inline">
<input type="checkbox" id="remember" name="remember">
<label for="remember"><?= translate('stay_logged_in', $i18n) ?></label>
</div>
<?php
}
?>
<div class="form-group">
<input type="submit" value="<?= translate('login', $i18n) ?>">
</div>
<?php } ?>
<div class="form-group">
<input type="submit" value="<?= translate('login', $i18n) ?>">
<?php
if ($oidcEnabled) {
if (!$password_login_disabled) {
?>
<span class="or-separator"><?= translate('or', $i18n) ?></span>
<?php
}
?>
<span class="or-separator"><?= translate('or', $i18n) ?></span>
<a class="button secondary-button" href="<?= htmlspecialchars($oidc_auth_url) ?>">
<?= translate('login_with', $i18n) ?> <?= htmlspecialchars($oidc_name) ?>
<?= translate('login_with', $i18n) ?> <?= htmlspecialchars($oidc_name) ?>
</a>
<?php
}

View File

@@ -1,87 +1,126 @@
{
"short_name": "Wallos",
"name": "Wallos - Subscription Tracker",
"icons": [
"short_name": "Wallos",
"name": "Wallos - Subscription Tracker",
"icons": [
{
"src": "images/icon/android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "images/icon/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "images/icon/maskable_icon_x192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
{
"src": "images/icon/maskable_icon_x512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"id": "com.wallos.app",
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "View your dashboard",
"url": "index.php",
"icons": [
{
"src": "images/icon/android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "images/icon/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "images/icon/maskable_icon_x192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
{
"src": "images/icon/maskable_icon_x512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
"src": "images/siteicons/pwa/dashboard.png",
"sizes": "96x96"
}
],
"start_url": "/",
"id": "com.wallos.app",
"shortcuts": [
]
},
{
"name": "Subscriptions",
"short_name": "Subscriptions",
"description": "View your subscriptions",
"url": "subscriptions.php",
"icons": [
{
"name": "Subscriptions",
"short_name": "Subscriptions",
"description": "View your subscriptions",
"url": "index.php",
"icons": [{ "src": "images/siteicons/pwa/subscriptions.png", "sizes": "96x96" }]
},
{
"name": "Calendar",
"short_name": "Calendar",
"description": "View your calendar",
"url": "calendar.php",
"icons": [{ "src": "images/siteicons/pwa/calendar.png", "sizes": "96x96" }]
},
{
"name": "Stats",
"short_name": "Stats",
"description": "View your statistics",
"url": "stats.php",
"icons": [{ "src": "images/siteicons/pwa/stats.png", "sizes": "96x96" }]
},
{
"name": "Settings",
"short_name": "Settings",
"description": "Change your settings",
"url": "settings.php",
"icons": [{ "src": "images/siteicons/pwa/settings.png", "sizes": "96x96" }]
},
{
"name": "About",
"short_name": "About",
"description": "More info about Wallos",
"url": "about.php",
"icons": [{ "src": "images/siteicons/pwa/about.png", "sizes": "96x96" }]
"src": "images/siteicons/pwa/subscriptions.png",
"sizes": "96x96"
}
],
"screenshots": [
]
},
{
"name": "Calendar",
"short_name": "Calendar",
"description": "View your calendar",
"url": "calendar.php",
"icons": [
{
"src": "images/screenshots/desktop.png",
"sizes": "1000x750",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "images/screenshots/mobile.png",
"sizes": "600x1000",
"type": "image/png"
"src": "images/siteicons/pwa/calendar.png",
"sizes": "96x96"
}
],
"background_color": "#FFFFFF",
"display": "standalone",
"scope": "/",
"theme_color": "#FFFFFF",
"description": "Wallos is a personal subscription tracker that helps you keep track of your subscriptions and save money.",
"orientation": "portrait-primary",
"display_override": ["window-controls-overlay"]
]
},
{
"name": "Stats",
"short_name": "Stats",
"description": "View your statistics",
"url": "stats.php",
"icons": [
{
"src": "images/siteicons/pwa/stats.png",
"sizes": "96x96"
}
]
},
{
"name": "Settings",
"short_name": "Settings",
"description": "Change your settings",
"url": "settings.php",
"icons": [
{
"src": "images/siteicons/pwa/settings.png",
"sizes": "96x96"
}
]
},
{
"name": "About",
"short_name": "About",
"description": "More info about Wallos",
"url": "about.php",
"icons": [
{
"src": "images/siteicons/pwa/about.png",
"sizes": "96x96"
}
]
}
],
"screenshots": [
{
"src": "images/screenshots/desktop.png",
"sizes": "1000x750",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "images/screenshots/mobile.png",
"sizes": "600x1000",
"type": "image/png"
}
],
"background_color": "#FFFFFF",
"display": "standalone",
"scope": "/",
"theme_color": "#FFFFFF",
"description": "Wallos is a personal subscription tracker that helps you keep track of your subscriptions and save money.",
"orientation": "portrait-primary",
"display_override": [
"window-controls-overlay"
]
}

51
migrations/000039.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
// This migration adds a "password_login_disabled" column to the "oauth_settings" table
// This migration also adds a "ai_settings" table to store AI settings.
// This migration also adds a "ai_recommendations" table to store AI recommendations.
$columnQuery = $db->query("SELECT * FROM pragma_table_info('oauth_settings') WHERE name='password_login_disabled'");
$columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false;
if ($columnRequired) {
$db->exec("ALTER TABLE oauth_settings ADD COLUMN password_login_disabled INTEGER DEFAULT 0");
}
// Check if ai_settings table exists, if not, create it
$tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_settings'");
$tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC);
if ($tableExists === false) {
$db->exec("
CREATE TABLE ai_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 0,
api_key TEXT,
model TEXT NOT NULL,
url TEXT,
run_schedule TEXT NOT NULL DEFAULT 'manual',
last_successful_run DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
}
// Check if ai_recommendations table exists, if not, create it
$tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'");
$tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC);
if ($tableExists === false) {
$db->exec("
CREATE TABLE ai_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
savings TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
}

View File

@@ -390,6 +390,7 @@ function saveOidcSettingsButton() {
const oidcScopes = document.getElementById("oidcScopes").value;
const oidcAuthStyle = document.getElementById("oidcAuthStyle").value;
const oidcAutoCreateUser = document.getElementById("oidcAutoCreateUser").checked ? 1 : 0;
const oidcPasswordLoginDisabled = document.getElementById("oidcPasswordLoginDisabled").checked ? 1 : 0;
const data = {
oidcName: oidcName,
@@ -403,7 +404,8 @@ function saveOidcSettingsButton() {
oidcUserIdentifierField: oidcUserIdentifierField,
oidcScopes: oidcScopes,
oidcAuthStyle: oidcAuthStyle,
oidcAutoCreateUser: oidcAutoCreateUser
oidcAutoCreateUser: oidcAutoCreateUser,
oidcPasswordLoginDisabled: oidcPasswordLoginDisabled
};

View File

@@ -1,874 +1,7 @@
let isSortOptionsOpen = false;
let scrollTopBeforeOpening = 0;
const shouldScroll = window.innerWidth <= 768;
function toggleOpenSubscription(subId) {
const subscriptionElement = document.querySelector('.subscription[data-id="' + subId + '"]');
subscriptionElement.classList.toggle('is-open');
}
function toggleSortOptions() {
const sortOptions = document.querySelector("#sort-options");
sortOptions.classList.toggle("is-open");
isSortOptionsOpen = !isSortOptionsOpen;
}
function toggleNotificationDays() {
const notifyCheckbox = document.querySelector("#notifications");
const notifyDaysBefore = document.querySelector("#notify_days_before");
notifyDaysBefore.disabled = !notifyCheckbox.checked;
}
function resetForm() {
const id = document.querySelector("#id");
id.value = "";
const formTitle = document.querySelector("#form-title");
formTitle.textContent = translate('add_subscription');
const logo = document.querySelector("#form-logo");
logo.src = "";
logo.style = 'display: none';
const logoUrl = document.querySelector("#logo-url");
logoUrl.value = "";
const logoSearchButton = document.querySelector("#logo-search-button");
logoSearchButton.classList.add("disabled");
const submitButton = document.querySelector("#save-button");
submitButton.disabled = false;
const autoRenew = document.querySelector("#auto_renew");
autoRenew.checked = true;
const startDate = document.querySelector("#start_date");
startDate.value = new Date().toISOString().split('T')[0];
const notifyDaysBefore = document.querySelector("#notify_days_before");
notifyDaysBefore.disabled = true;
const replacementSubscriptionIdSelect = document.querySelector("#replacement_subscription_id");
replacementSubscriptionIdSelect.value = "0";
const replacementSubscription = document.querySelector(`#replacement_subscritpion`);
replacementSubscription.classList.add("hide");
const form = document.querySelector("#subs-form");
form.reset();
closeLogoSearch();
const deleteButton = document.querySelector("#deletesub");
deleteButton.style = 'display: none';
deleteButton.removeAttribute("onClick");
}
function fillEditFormFields(subscription) {
const formTitle = document.querySelector("#form-title");
formTitle.textContent = translate('edit_subscription');
const logo = document.querySelector("#form-logo");
const logoFile = subscription.logo !== null ? "images/uploads/logos/" + subscription.logo : "";
if (logoFile) {
logo.src = logoFile;
logo.style = 'display: block';
}
const logoSearchButton = document.querySelector("#logo-search-button");
logoSearchButton.classList.remove("disabled");
const id = document.querySelector("#id");
id.value = subscription.id;
const name = document.querySelector("#name");
name.value = subscription.name;
const price = document.querySelector("#price");
price.value = subscription.price;
const currencySelect = document.querySelector("#currency");
currencySelect.value = subscription.currency_id.toString();
const frequencySelect = document.querySelector("#frequency");
frequencySelect.value = subscription.frequency;
const cycleSelect = document.querySelector("#cycle");
cycleSelect.value = subscription.cycle;
const paymentSelect = document.querySelector("#payment_method");
paymentSelect.value = subscription.payment_method_id;
const categorySelect = document.querySelector("#category");
categorySelect.value = subscription.category_id;
const payerSelect = document.querySelector("#payer_user");
payerSelect.value = subscription.payer_user_id;
const startDate = document.querySelector("#start_date");
startDate.value = subscription.start_date;
const nextPament = document.querySelector("#next_payment");
nextPament.value = subscription.next_payment;
const cancellationDate = document.querySelector("#cancellation_date");
cancellationDate.value = subscription.cancellation_date;
const notes = document.querySelector("#notes");
notes.value = subscription.notes;
const inactive = document.querySelector("#inactive");
inactive.checked = subscription.inactive;
const url = document.querySelector("#url");
url.value = subscription.url;
const autoRenew = document.querySelector("#auto_renew");
if (autoRenew) {
autoRenew.checked = subscription.auto_renew;
}
const notifications = document.querySelector("#notifications");
if (notifications) {
notifications.checked = subscription.notify;
}
const notifyDaysBefore = document.querySelector("#notify_days_before");
notifyDaysBefore.value = subscription.notify_days_before ?? 0;
if (subscription.notify === 1) {
notifyDaysBefore.disabled = false;
}
const replacementSubscriptionIdSelect = document.querySelector("#replacement_subscription_id");
replacementSubscriptionIdSelect.value = subscription.replacement_subscription_id ?? 0;
const replacementSubscription = document.querySelector(`#replacement_subscritpion`);
if (subscription.inactive) {
replacementSubscription.classList.remove("hide");
} else {
replacementSubscription.classList.add("hide");
}
const deleteButton = document.querySelector("#deletesub");
deleteButton.style = 'display: block';
deleteButton.setAttribute("onClick", `deleteSubscription(event, ${subscription.id})`);
const modal = document.getElementById('subscription-form');
modal.classList.add("is-open");
}
function openEditSubscription(event, id) {
event.stopPropagation();
scrollTopBeforeOpening = window.scrollY;
const body = document.querySelector('body');
body.classList.add('no-scroll');
const url = `endpoints/subscription/get.php?id=${id}`;
fetch(url)
.then((response) => {
if (response.ok) {
return response.json();
} else {
showErrorMessage(translate('failed_to_load_subscription'));
}
})
.then((data) => {
if (data.error || data === "Error") {
showErrorMessage(translate('failed_to_load_subscription'));
} else {
const subscription = data;
fillEditFormFields(subscription);
}
})
.catch((error) => {
console.log(error);
showErrorMessage(translate('failed_to_load_subscription'));
});
}
function addSubscription() {
resetForm();
const modal = document.getElementById('subscription-form');
const startDate = document.querySelector("#start_date");
startDate.value = new Date().toISOString().split('T')[0];
modal.classList.add("is-open");
const body = document.querySelector('body');
body.classList.add('no-scroll');
}
function closeAddSubscription() {
const modal = document.getElementById('subscription-form');
modal.classList.remove("is-open");
const body = document.querySelector('body');
body.classList.remove('no-scroll');
if (shouldScroll) {
window.scrollTo(0, scrollTopBeforeOpening);
}
resetForm();
}
function handleFileSelect(event) {
const fileInput = event.target;
const logoPreview = document.querySelector('.logo-preview');
const logoImg = logoPreview.querySelector('img');
const logoUrl = document.querySelector("#logo-url");
logoUrl.value = "";
if (fileInput.files && fileInput.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
logoImg.src = e.target.result;
logoImg.style.display = 'block';
};
reader.readAsDataURL(fileInput.files[0]);
}
}
function deleteSubscription(event, id) {
event.stopPropagation();
event.preventDefault();
if (confirm(translate('confirm_delete_subscription'))) {
fetch(`endpoints/subscription/delete.php?id=${id}`, {
method: 'DELETE',
})
.then(response => {
if (response.ok) {
showSuccessMessage(translate('subscription_deleted'));
fetchSubscriptions(null, null, "delete");
closeAddSubscription();
} else {
showErrorMessage(translate('error_deleting_subscription'));
}
})
.catch(error => {
console.error('Error:', error);
});
}
}
function cloneSubscription(event, id) {
event.stopPropagation();
event.preventDefault();
const url = `endpoints/subscription/clone.php?id=${id}`;
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(translate('network_response_error'));
}
return response.json();
})
.then(data => {
if (data.success) {
const id = data.id;
fetchSubscriptions(id, event, "clone");
showSuccessMessage(decodeURI(data.message));
} else {
showErrorMessage(data.message || translate('error'));
}
})
.catch(error => {
showErrorMessage(error.message || translate('error'));
});
}
function renewSubscription(event, id) {
event.stopPropagation();
event.preventDefault();
const url = `endpoints/subscription/renew.php?id=${id}`;
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(translate('network_response_error'));
}
return response.json();
})
.then(data => {
if (data.success) {
const id = data.id;
fetchSubscriptions(id, event, "renew");
showSuccessMessage(decodeURI(data.message));
} else {
showErrorMessage(data.message || translate('error'));
}
})
.catch(error => {
showErrorMessage(error.message || translate('error'));
});
}
function setSearchButtonStatus() {
const nameInput = document.querySelector("#name");
const hasSearchTerm = nameInput.value.trim().length > 0;
const logoSearchButton = document.querySelector("#logo-search-button");
if (hasSearchTerm) {
logoSearchButton.classList.remove("disabled");
} else {
logoSearchButton.classList.add("disabled");
}
}
function searchLogo() {
const nameInput = document.querySelector("#name");
const searchTerm = nameInput.value.trim();
if (searchTerm !== "") {
const logoSearchPopup = document.querySelector("#logo-search-results");
logoSearchPopup.classList.add("is-open");
const imageSearchUrl = `endpoints/logos/search.php?search=${searchTerm}`;
fetch(imageSearchUrl)
.then(response => response.json())
.then(data => {
if (data.imageUrls) {
displayImageResults(data.imageUrls);
} else if (data.error) {
console.error(data.error);
}
})
.catch(error => {
console.error(translate('error_fetching_image_results'), error);
});
} else {
nameInput.focus();
}
}
function displayImageResults(imageSources) {
const logoResults = document.querySelector("#logo-search-images");
logoResults.innerHTML = "";
imageSources.forEach(src => {
const img = document.createElement("img");
img.src = src;
img.onclick = function () {
selectWebLogo(src);
};
img.onerror = function () {
this.parentNode.removeChild(this);
};
logoResults.appendChild(img);
});
}
function selectWebLogo(url) {
closeLogoSearch();
const logoPreview = document.querySelector("#form-logo");
const logoUrl = document.querySelector("#logo-url");
logoPreview.src = url;
logoPreview.style.display = 'block';
logoUrl.value = url;
}
function closeLogoSearch() {
const logoSearchPopup = document.querySelector("#logo-search-results");
logoSearchPopup.classList.remove("is-open");
const logoResults = document.querySelector("#logo-search-images");
logoResults.innerHTML = "";
}
function fetchSubscriptions(id, event, initiator) {
const subscriptionsContainer = document.querySelector("#subscriptions");
let getSubscriptions = "endpoints/subscriptions/get.php";
if (activeFilters['categories'].length > 0) {
getSubscriptions += `?categories=${activeFilters['categories']}`;
}
if (activeFilters['members'].length > 0) {
getSubscriptions += getSubscriptions.includes("?") ? `&members=${activeFilters['members']}` : `?members=${activeFilters['members']}`;
}
if (activeFilters['payments'].length > 0) {
getSubscriptions += getSubscriptions.includes("?") ? `&payments=${activeFilters['payments']}` : `?payments=${activeFilters['payments']}`;
}
if (activeFilters['state'] !== "") {
getSubscriptions += getSubscriptions.includes("?") ? `&state=${activeFilters['state']}` : `?state=${activeFilters['state']}`;
}
if (activeFilters['renewalType'] !== "") {
getSubscriptions += getSubscriptions.includes("?") ? `&renewalType=${activeFilters['renewalType']}` : `?renewalType=${activeFilters['renewalType']}`;
}
fetch(getSubscriptions)
.then(response => response.text())
.then(data => {
if (data) {
subscriptionsContainer.innerHTML = data;
const mainActions = document.querySelector("#main-actions");
if (data.includes("no-matching-subscriptions")) {
// mainActions.classList.add("hidden");
} else {
mainActions.classList.remove("hidden");
}
}
if (initiator == "clone" && id && event) {
openEditSubscription(event, id);
}
setSwipeElements();
if (initiator === "add") {
if (document.getElementsByClassName('subscription').length === 1) {
setTimeout(() => {
swipeHintAnimation();
}, 1000);
}
}
})
.catch(error => {
console.error(translate('error_reloading_subscription'), error);
});
}
function setSortOption(sortOption) {
const sortOptionsContainer = document.querySelector("#sort-options");
const sortOptionsList = sortOptionsContainer.querySelectorAll("li");
sortOptionsList.forEach((option) => {
if (option.getAttribute("id") === "sort-" + sortOption) {
option.classList.add("selected");
} else {
option.classList.remove("selected");
}
});
const daysToExpire = 30;
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + daysToExpire);
const cookieValue = encodeURIComponent(sortOption) + '; expires=' + expirationDate.toUTCString();
document.cookie = 'sortOrder=' + cookieValue + '; SameSite=Strict';
fetchSubscriptions(null, null, "sort");
toggleSortOptions();
}
function convertSvgToPng(file, callback) {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.src = e.target.result;
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const pngDataUrl = canvas.toDataURL('image/png');
const pngFile = dataURLtoFile(pngDataUrl, file.name.replace(".svg", ".png"));
callback(pngFile);
};
};
reader.readAsDataURL(file);
}
function dataURLtoFile(dataurl, filename) {
let arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
function submitFormData(formData, submitButton, endpoint) {
fetch(endpoint, {
method: "POST",
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.status === "Success") {
showSuccessMessage(data.message);
fetchSubscriptions(null, null, "add");
closeAddSubscription();
}
})
.catch((error) => {
showErrorMessage(error);
submitButton.disabled = false;
});
}
document.addEventListener('DOMContentLoaded', function () {
const subscriptionForm = document.querySelector("#subs-form");
const submitButton = document.querySelector("#save-button");
const endpoint = "endpoints/subscription/add.php";
subscriptionForm.addEventListener("submit", function (e) {
e.preventDefault();
submitButton.disabled = true;
const formData = new FormData(subscriptionForm);
const fileInput = document.querySelector("#logo");
const file = fileInput.files[0];
if (file && file.type === "image/svg+xml") {
convertSvgToPng(file, function (pngFile) {
formData.set("logo", pngFile);
submitFormData(formData, submitButton, endpoint);
});
} else {
submitFormData(formData, submitButton, endpoint);
}
});
document.addEventListener('mousedown', function (event) {
const sortOptions = document.querySelector('#sort-options');
const sortButton = document.querySelector("#sort-button");
if (!sortOptions.contains(event.target) && !sortButton.contains(event.target) && isSortOptionsOpen) {
sortOptions.classList.remove('is-open');
isSortOptionsOpen = false;
}
});
document.querySelector('#sort-options').addEventListener('focus', function () {
isSortOptionsOpen = true;
});
});
function searchSubscriptions() {
const searchInput = document.querySelector("#search");
const searchContainer = searchInput.parentElement;
const searchTerm = searchInput.value.trim().toLowerCase();
if (searchTerm.length > 0) {
searchContainer.classList.add("has-text");
} else {
searchContainer.classList.remove("has-text");
}
const subscriptions = document.querySelectorAll(".subscription");
subscriptions.forEach(subscription => {
const name = subscription.getAttribute('data-name').toLowerCase();
if (!name.includes(searchTerm)) {
subscription.parentElement.classList.add("hide");
} else {
subscription.parentElement.classList.remove("hide");
}
});
}
function clearSearch() {
const searchInput = document.querySelector("#search");
searchInput.value = "";
searchSubscriptions();
}
function closeSubMenus() {
var subMenus = document.querySelectorAll('.filtermenu-submenu-content');
subMenus.forEach(subMenu => {
subMenu.classList.remove('is-open');
});
}
function setSwipeElements() {
if (window.mobileNavigation) {
const swipeElements = document.querySelectorAll('.subscription');
swipeElements.forEach((element) => {
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;
let translateX = 0;
const maxTranslateX = element.classList.contains('manual') ? -240 : -180;
element.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
element.style.transition = ''; // Remove transition for smooth dragging
});
element.addEventListener('touchmove', (e) => {
currentX = e.touches[0].clientX;
currentY = e.touches[0].clientY;
const diffX = currentX - startX;
const diffY = currentY - startY;
// Check if the swipe is more horizontal than vertical
if (Math.abs(diffX) > Math.abs(diffY)) {
e.preventDefault(); // Prevent vertical scrolling
// Only update translateX if swiping within allowed range
if (!(translateX === maxTranslateX && diffX < 0)) {
translateX = Math.min(0, Math.max(maxTranslateX, diffX)); // Clamp translateX between -180 and 0
element.style.transform = `translateX(${translateX}px)`;
}
}
});
element.addEventListener('touchend', () => {
// Check the final swipe position to determine snap behavior
if (translateX < maxTranslateX / 2) {
// If more than halfway to the left, snap fully open
translateX = maxTranslateX;
} else {
// If swiped less than halfway left or swiped right, snap back to closed
translateX = 0;
}
element.style.transition = 'transform 0.2s ease'; // Smooth snap effect
element.style.transform = `translateX(${translateX}px)`;
element.style.zIndex = '1';
});
});
}
}
const activeFilters = [];
activeFilters['categories'] = [];
activeFilters['members'] = [];
activeFilters['payments'] = [];
activeFilters['state'] = "";
activeFilters['renewalType'] = "";
document.addEventListener("DOMContentLoaded", function () {
var filtermenu = document.querySelector('#filtermenu-button');
filtermenu.addEventListener('click', function () {
this.parentElement.querySelector('.filtermenu-content').classList.toggle('is-open');
closeSubMenus();
document.querySelectorAll(".ai-recommendation-item").forEach(function (item) {
item.addEventListener("click", function () {
item.classList.toggle("expanded");
});
});
document.addEventListener('click', function (e) {
var filtermenuContent = document.querySelector('.filtermenu-content');
if (filtermenuContent.classList.contains('is-open')) {
var subMenus = document.querySelectorAll('.filtermenu-submenu');
var clickedInsideSubmenu = Array.from(subMenus).some(subMenu => subMenu.contains(e.target) || subMenu === e.target);
if (!filtermenu.contains(e.target) && !clickedInsideSubmenu) {
closeSubMenus();
filtermenuContent.classList.remove('is-open');
}
}
});
setSwipeElements();
});
function toggleSubMenu(subMenu) {
var subMenu = document.getElementById("filter-" + subMenu);
if (subMenu.classList.contains("is-open")) {
closeSubMenus();
} else {
closeSubMenus();
subMenu.classList.add("is-open");
}
}
function toggleReplacementSub() {
const checkbox = document.getElementById('inactive');
const replacementSubscription = document.querySelector(`#replacement_subscritpion`);
if (checkbox.checked) {
replacementSubscription.classList.remove("hide");
} else {
replacementSubscription.classList.add("hide");
}
}
document.querySelectorAll('.filter-item').forEach(function (item) {
item.addEventListener('click', function (e) {
const searchInput = document.querySelector("#search");
searchInput.value = "";
if (this.hasAttribute('data-categoryid')) {
const categoryId = this.getAttribute('data-categoryid');
if (activeFilters['categories'].includes(categoryId)) {
const categoryIndex = activeFilters['categories'].indexOf(categoryId);
activeFilters['categories'].splice(categoryIndex, 1);
this.classList.remove('selected');
} else {
activeFilters['categories'].push(categoryId);
this.classList.add('selected');
}
} else if (this.hasAttribute('data-memberid')) {
const memberId = this.getAttribute('data-memberid');
if (activeFilters['members'].includes(memberId)) {
const memberIndex = activeFilters['members'].indexOf(memberId);
activeFilters['members'].splice(memberIndex, 1);
this.classList.remove('selected');
} else {
activeFilters['members'].push(memberId);
this.classList.add('selected');
}
} else if (this.hasAttribute('data-paymentid')) {
const paymentId = this.getAttribute('data-paymentid');
if (activeFilters['payments'].includes(paymentId)) {
const paymentIndex = activeFilters['payments'].indexOf(paymentId);
activeFilters['payments'].splice(paymentIndex, 1);
this.classList.remove('selected');
} else {
activeFilters['payments'].push(paymentId);
this.classList.add('selected');
}
} else if (this.hasAttribute('data-state')) {
const state = this.getAttribute('data-state');
if (activeFilters['state'] === state) {
activeFilters['state'] = "";
this.classList.remove('selected');
} else {
activeFilters['state'] = state;
Array.from(this.parentNode.children).forEach(sibling => {
sibling.classList.remove('selected');
});
this.classList.add('selected');
}
} else if (this.hasAttribute('data-renewaltype')) {
const renewalType = this.getAttribute('data-renewaltype');
if (activeFilters['renewalType'] === renewalType) {
activeFilters['renewalType'] = "";
this.classList.remove('selected');
} else {
activeFilters['renewalType'] = renewalType;
Array.from(this.parentNode.children).forEach(sibling => {
sibling.classList.remove('selected');
});
this.classList.add('selected');
}
}
if (activeFilters['categories'].length > 0 || activeFilters['members'].length > 0 ||
activeFilters['payments'].length > 0 || activeFilters['state'] !== "" ||
activeFilters['renewalType'] !== "") {
document.querySelector('#clear-filters').classList.remove('hide');
} else {
document.querySelector('#clear-filters').classList.add('hide');
}
fetchSubscriptions(null, null, "filter");
});
});
function clearFilters() {
const searchInput = document.querySelector("#search");
searchInput.value = "";
activeFilters['categories'] = [];
activeFilters['members'] = [];
activeFilters['payments'] = [];
activeFilters['state'] = "";
activeFilters['renewalType'] = "";
document.querySelectorAll('.filter-item').forEach(function (item) {
item.classList.remove('selected');
});
document.querySelector('#clear-filters').classList.add('hide');
fetchSubscriptions(null, null, "clearfilters");
}
let currentActions = null;
document.addEventListener('click', function (event) {
// Check if click was outside currentActions
if (currentActions && !currentActions.contains(event.target)) {
// Click was outside currentActions, close currentActions
currentActions.classList.remove('is-open');
currentActions = null;
}
});
function expandActions(event, subscriptionId) {
event.stopPropagation();
event.preventDefault();
const subscriptionDiv = document.querySelector(`.subscription[data-id="${subscriptionId}"]`);
const actions = subscriptionDiv.querySelector('.actions');
// Close all other open actions
const allActions = document.querySelectorAll('.actions.is-open');
allActions.forEach((openAction) => {
if (openAction !== actions) {
openAction.classList.remove('is-open');
}
});
// Toggle the clicked actions
actions.classList.toggle('is-open');
// Update currentActions
if (actions.classList.contains('is-open')) {
currentActions = actions;
} else {
currentActions = null;
}
}
function swipeHintAnimation() {
if (window.mobileNavigation && window.matchMedia('(max-width: 768px)').matches) {
const maxAnimations = 3;
const cookieName = 'swipeHintCount';
let count = parseInt(getCookie(cookieName)) || 0;
if (count < maxAnimations) {
const firstElement = document.querySelector('.subscription');
if (firstElement) {
firstElement.style.transition = 'transform 0.3s ease';
firstElement.style.transform = 'translateX(-80px)';
setTimeout(() => {
firstElement.style.transform = 'translateX(0px)';
firstElement.style.zIndex = '1';
}, 600);
}
count++;
document.cookie = `${cookieName}=${count}; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/; SameSite=Strict`;
}
}
}
function autoFillNextPaymentDate(e) {
e.preventDefault();
const frequencySelect = document.querySelector("#frequency");
const cycleSelect = document.querySelector("#cycle");
const startDate = document.querySelector("#start_date");
const nextPayment = document.querySelector("#next_payment");
// Do nothing if frequency, cycle, or start date is not set
if (!frequencySelect.value || !cycleSelect.value || !startDate.value || isNaN(Date.parse(startDate.value))) {
console.log(frequencySelect.value, cycleSelect.value, startDate.value);
return;
}
const today = new Date();
const cycle = cycleSelect.value;
const frequency = Number(frequencySelect.value);
const nextDate = new Date(startDate.value);
let safetyCounter = 0;
const maxIterations = 1000;
while (nextDate <= today && safetyCounter < maxIterations) {
switch (cycle) {
case '1': // Days
nextDate.setDate(nextDate.getDate() + frequency);
break;
case '2': // Weeks
nextDate.setDate(nextDate.getDate() + 7 * frequency);
break;
case '3': // Months
nextDate.setMonth(nextDate.getMonth() + frequency);
break;
case '4': // Years
nextDate.setFullYear(nextDate.getFullYear() + frequency);
break;
default:
}
safetyCounter++;
}
if (safetyCounter === maxIterations) {
return;
}
nextPayment.value = toISOStringWithTimezone(nextDate).substring(0, 10);
}
function toISOStringWithTimezone(date) {
const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0');
const tzOffset = -date.getTimezoneOffset();
const sign = tzOffset >= 0 ? '+' : '-';
const hoursOffset = pad(tzOffset / 60);
const minutesOffset = pad(tzOffset % 60);
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
sign + hoursOffset +
':' + minutesOffset;
}
window.addEventListener('load', () => {
if (document.querySelector('.subscription')) {
swipeHintAnimation();
}
});
});

View File

@@ -887,3 +887,115 @@ var sortable = Sortable.create(el, {
saveCategorySorting();
},
});
function fetch_ai_models() {
const endpoint = 'endpoints/ai/fetch_models.php';
const type = document.querySelector("#ai_type").value;
const api_key = document.querySelector("#ai_api_key").value.trim();
const ollama_host = document.querySelector("#ai_ollama_host").value.trim();
const modelSelect = document.querySelector("#ai_model");
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ type, api_key, ollama_host })
})
.then(response => response.json())
.then(data => {
if (data.success) {
modelSelect.innerHTML = '';
data.models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
modelSelect.appendChild(option);
});
} else {
showErrorMessage(data.errorMessage);
}
})
.catch(error => {
showErrorMessage(translate('unknown_error'));
});
}
function toggleAiInputs() {
const aiTypeSelect = document.getElementById("ai_type");
const apiKeyInput = document.getElementById("ai_api_key");
const ollamaHostInput = document.getElementById("ai_ollama_host");
const type = aiTypeSelect.value;
if (type === "ollama") {
apiKeyInput.classList.add("hidden");
ollamaHostInput.classList.remove("hidden");
} else {
apiKeyInput.classList.remove("hidden");
ollamaHostInput.classList.add("hidden");
}
}
function saveAiSettingsButton() {
const aiEnabled = document.querySelector("#ai_enabled").checked;
const aiType = document.querySelector("#ai_type").value;
const aiApiKey = document.querySelector("#ai_api_key").value.trim();
const aiOllamaHost = document.querySelector("#ai_ollama_host").value.trim();
const aiModel = document.querySelector("#ai_model").value;
fetch('endpoints/ai/save_settings.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ai_enabled: aiEnabled, ai_type: aiType, api_key: aiApiKey, ollama_host: aiOllamaHost, model: aiModel })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showSuccessMessage(data.message);
const runAiActionButton = document.querySelector("#runAiRecommendations");
if (data.enabled) {
runAiActionButton.classList.remove("hidden");
} else {
runAiActionButton.classList.add("hidden");
}
} else {
showErrorMessage(data.errorMessage);
}
})
.catch(error => {
showErrorMessage(translate('unknown_error'));
});
}
function runAiRecommendations() {
const endpoint = 'endpoints/ai/generate_recommendations.php';
const button = document.querySelector("#runAiRecommendations");
const spinner = document.querySelector("#aiSpinner");
button.classList.add("hidden");
spinner.classList.remove("hidden");
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showSuccessMessage(data.message);
} else {
showErrorMessage(data.errorMessage);
}
})
.catch(error => {
showErrorMessage(translate('unknown_error'));
})
.finally(() => {
button.classList.remove("hidden");
spinner.classList.add("hidden");
});
}

874
scripts/subscriptions.js Normal file
View File

@@ -0,0 +1,874 @@
let isSortOptionsOpen = false;
let scrollTopBeforeOpening = 0;
const shouldScroll = window.innerWidth <= 768;
function toggleOpenSubscription(subId) {
const subscriptionElement = document.querySelector('.subscription[data-id="' + subId + '"]');
subscriptionElement.classList.toggle('is-open');
}
function toggleSortOptions() {
const sortOptions = document.querySelector("#sort-options");
sortOptions.classList.toggle("is-open");
isSortOptionsOpen = !isSortOptionsOpen;
}
function toggleNotificationDays() {
const notifyCheckbox = document.querySelector("#notifications");
const notifyDaysBefore = document.querySelector("#notify_days_before");
notifyDaysBefore.disabled = !notifyCheckbox.checked;
}
function resetForm() {
const id = document.querySelector("#id");
id.value = "";
const formTitle = document.querySelector("#form-title");
formTitle.textContent = translate('add_subscription');
const logo = document.querySelector("#form-logo");
logo.src = "";
logo.style = 'display: none';
const logoUrl = document.querySelector("#logo-url");
logoUrl.value = "";
const logoSearchButton = document.querySelector("#logo-search-button");
logoSearchButton.classList.add("disabled");
const submitButton = document.querySelector("#save-button");
submitButton.disabled = false;
const autoRenew = document.querySelector("#auto_renew");
autoRenew.checked = true;
const startDate = document.querySelector("#start_date");
startDate.value = new Date().toISOString().split('T')[0];
const notifyDaysBefore = document.querySelector("#notify_days_before");
notifyDaysBefore.disabled = true;
const replacementSubscriptionIdSelect = document.querySelector("#replacement_subscription_id");
replacementSubscriptionIdSelect.value = "0";
const replacementSubscription = document.querySelector(`#replacement_subscritpion`);
replacementSubscription.classList.add("hide");
const form = document.querySelector("#subs-form");
form.reset();
closeLogoSearch();
const deleteButton = document.querySelector("#deletesub");
deleteButton.style = 'display: none';
deleteButton.removeAttribute("onClick");
}
function fillEditFormFields(subscription) {
const formTitle = document.querySelector("#form-title");
formTitle.textContent = translate('edit_subscription');
const logo = document.querySelector("#form-logo");
const logoFile = subscription.logo !== null ? "images/uploads/logos/" + subscription.logo : "";
if (logoFile) {
logo.src = logoFile;
logo.style = 'display: block';
}
const logoSearchButton = document.querySelector("#logo-search-button");
logoSearchButton.classList.remove("disabled");
const id = document.querySelector("#id");
id.value = subscription.id;
const name = document.querySelector("#name");
name.value = subscription.name;
const price = document.querySelector("#price");
price.value = subscription.price;
const currencySelect = document.querySelector("#currency");
currencySelect.value = subscription.currency_id.toString();
const frequencySelect = document.querySelector("#frequency");
frequencySelect.value = subscription.frequency;
const cycleSelect = document.querySelector("#cycle");
cycleSelect.value = subscription.cycle;
const paymentSelect = document.querySelector("#payment_method");
paymentSelect.value = subscription.payment_method_id;
const categorySelect = document.querySelector("#category");
categorySelect.value = subscription.category_id;
const payerSelect = document.querySelector("#payer_user");
payerSelect.value = subscription.payer_user_id;
const startDate = document.querySelector("#start_date");
startDate.value = subscription.start_date;
const nextPament = document.querySelector("#next_payment");
nextPament.value = subscription.next_payment;
const cancellationDate = document.querySelector("#cancellation_date");
cancellationDate.value = subscription.cancellation_date;
const notes = document.querySelector("#notes");
notes.value = subscription.notes;
const inactive = document.querySelector("#inactive");
inactive.checked = subscription.inactive;
const url = document.querySelector("#url");
url.value = subscription.url;
const autoRenew = document.querySelector("#auto_renew");
if (autoRenew) {
autoRenew.checked = subscription.auto_renew;
}
const notifications = document.querySelector("#notifications");
if (notifications) {
notifications.checked = subscription.notify;
}
const notifyDaysBefore = document.querySelector("#notify_days_before");
notifyDaysBefore.value = subscription.notify_days_before ?? 0;
if (subscription.notify === 1) {
notifyDaysBefore.disabled = false;
}
const replacementSubscriptionIdSelect = document.querySelector("#replacement_subscription_id");
replacementSubscriptionIdSelect.value = subscription.replacement_subscription_id ?? 0;
const replacementSubscription = document.querySelector(`#replacement_subscritpion`);
if (subscription.inactive) {
replacementSubscription.classList.remove("hide");
} else {
replacementSubscription.classList.add("hide");
}
const deleteButton = document.querySelector("#deletesub");
deleteButton.style = 'display: block';
deleteButton.setAttribute("onClick", `deleteSubscription(event, ${subscription.id})`);
const modal = document.getElementById('subscription-form');
modal.classList.add("is-open");
}
function openEditSubscription(event, id) {
event.stopPropagation();
scrollTopBeforeOpening = window.scrollY;
const body = document.querySelector('body');
body.classList.add('no-scroll');
const url = `endpoints/subscription/get.php?id=${id}`;
fetch(url)
.then((response) => {
if (response.ok) {
return response.json();
} else {
showErrorMessage(translate('failed_to_load_subscription'));
}
})
.then((data) => {
if (data.error || data === "Error") {
showErrorMessage(translate('failed_to_load_subscription'));
} else {
const subscription = data;
fillEditFormFields(subscription);
}
})
.catch((error) => {
console.log(error);
showErrorMessage(translate('failed_to_load_subscription'));
});
}
function addSubscription() {
resetForm();
const modal = document.getElementById('subscription-form');
const startDate = document.querySelector("#start_date");
startDate.value = new Date().toISOString().split('T')[0];
modal.classList.add("is-open");
const body = document.querySelector('body');
body.classList.add('no-scroll');
}
function closeAddSubscription() {
const modal = document.getElementById('subscription-form');
modal.classList.remove("is-open");
const body = document.querySelector('body');
body.classList.remove('no-scroll');
if (shouldScroll) {
window.scrollTo(0, scrollTopBeforeOpening);
}
resetForm();
}
function handleFileSelect(event) {
const fileInput = event.target;
const logoPreview = document.querySelector('.logo-preview');
const logoImg = logoPreview.querySelector('img');
const logoUrl = document.querySelector("#logo-url");
logoUrl.value = "";
if (fileInput.files && fileInput.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
logoImg.src = e.target.result;
logoImg.style.display = 'block';
};
reader.readAsDataURL(fileInput.files[0]);
}
}
function deleteSubscription(event, id) {
event.stopPropagation();
event.preventDefault();
if (confirm(translate('confirm_delete_subscription'))) {
fetch(`endpoints/subscription/delete.php?id=${id}`, {
method: 'DELETE',
})
.then(response => {
if (response.ok) {
showSuccessMessage(translate('subscription_deleted'));
fetchSubscriptions(null, null, "delete");
closeAddSubscription();
} else {
showErrorMessage(translate('error_deleting_subscription'));
}
})
.catch(error => {
console.error('Error:', error);
});
}
}
function cloneSubscription(event, id) {
event.stopPropagation();
event.preventDefault();
const url = `endpoints/subscription/clone.php?id=${id}`;
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(translate('network_response_error'));
}
return response.json();
})
.then(data => {
if (data.success) {
const id = data.id;
fetchSubscriptions(id, event, "clone");
showSuccessMessage(decodeURI(data.message));
} else {
showErrorMessage(data.message || translate('error'));
}
})
.catch(error => {
showErrorMessage(error.message || translate('error'));
});
}
function renewSubscription(event, id) {
event.stopPropagation();
event.preventDefault();
const url = `endpoints/subscription/renew.php?id=${id}`;
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(translate('network_response_error'));
}
return response.json();
})
.then(data => {
if (data.success) {
const id = data.id;
fetchSubscriptions(id, event, "renew");
showSuccessMessage(decodeURI(data.message));
} else {
showErrorMessage(data.message || translate('error'));
}
})
.catch(error => {
showErrorMessage(error.message || translate('error'));
});
}
function setSearchButtonStatus() {
const nameInput = document.querySelector("#name");
const hasSearchTerm = nameInput.value.trim().length > 0;
const logoSearchButton = document.querySelector("#logo-search-button");
if (hasSearchTerm) {
logoSearchButton.classList.remove("disabled");
} else {
logoSearchButton.classList.add("disabled");
}
}
function searchLogo() {
const nameInput = document.querySelector("#name");
const searchTerm = nameInput.value.trim();
if (searchTerm !== "") {
const logoSearchPopup = document.querySelector("#logo-search-results");
logoSearchPopup.classList.add("is-open");
const imageSearchUrl = `endpoints/logos/search.php?search=${searchTerm}`;
fetch(imageSearchUrl)
.then(response => response.json())
.then(data => {
if (data.imageUrls) {
displayImageResults(data.imageUrls);
} else if (data.error) {
console.error(data.error);
}
})
.catch(error => {
console.error(translate('error_fetching_image_results'), error);
});
} else {
nameInput.focus();
}
}
function displayImageResults(imageSources) {
const logoResults = document.querySelector("#logo-search-images");
logoResults.innerHTML = "";
imageSources.forEach(src => {
const img = document.createElement("img");
img.src = src;
img.onclick = function () {
selectWebLogo(src);
};
img.onerror = function () {
this.parentNode.removeChild(this);
};
logoResults.appendChild(img);
});
}
function selectWebLogo(url) {
closeLogoSearch();
const logoPreview = document.querySelector("#form-logo");
const logoUrl = document.querySelector("#logo-url");
logoPreview.src = url;
logoPreview.style.display = 'block';
logoUrl.value = url;
}
function closeLogoSearch() {
const logoSearchPopup = document.querySelector("#logo-search-results");
logoSearchPopup.classList.remove("is-open");
const logoResults = document.querySelector("#logo-search-images");
logoResults.innerHTML = "";
}
function fetchSubscriptions(id, event, initiator) {
const subscriptionsContainer = document.querySelector("#subscriptions");
let getSubscriptions = "endpoints/subscriptions/get.php";
if (activeFilters['categories'].length > 0) {
getSubscriptions += `?categories=${activeFilters['categories']}`;
}
if (activeFilters['members'].length > 0) {
getSubscriptions += getSubscriptions.includes("?") ? `&members=${activeFilters['members']}` : `?members=${activeFilters['members']}`;
}
if (activeFilters['payments'].length > 0) {
getSubscriptions += getSubscriptions.includes("?") ? `&payments=${activeFilters['payments']}` : `?payments=${activeFilters['payments']}`;
}
if (activeFilters['state'] !== "") {
getSubscriptions += getSubscriptions.includes("?") ? `&state=${activeFilters['state']}` : `?state=${activeFilters['state']}`;
}
if (activeFilters['renewalType'] !== "") {
getSubscriptions += getSubscriptions.includes("?") ? `&renewalType=${activeFilters['renewalType']}` : `?renewalType=${activeFilters['renewalType']}`;
}
fetch(getSubscriptions)
.then(response => response.text())
.then(data => {
if (data) {
subscriptionsContainer.innerHTML = data;
const mainActions = document.querySelector("#main-actions");
if (data.includes("no-matching-subscriptions")) {
// mainActions.classList.add("hidden");
} else {
mainActions.classList.remove("hidden");
}
}
if (initiator == "clone" && id && event) {
openEditSubscription(event, id);
}
setSwipeElements();
if (initiator === "add") {
if (document.getElementsByClassName('subscription').length === 1) {
setTimeout(() => {
swipeHintAnimation();
}, 1000);
}
}
})
.catch(error => {
console.error(translate('error_reloading_subscription'), error);
});
}
function setSortOption(sortOption) {
const sortOptionsContainer = document.querySelector("#sort-options");
const sortOptionsList = sortOptionsContainer.querySelectorAll("li");
sortOptionsList.forEach((option) => {
if (option.getAttribute("id") === "sort-" + sortOption) {
option.classList.add("selected");
} else {
option.classList.remove("selected");
}
});
const daysToExpire = 30;
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + daysToExpire);
const cookieValue = encodeURIComponent(sortOption) + '; expires=' + expirationDate.toUTCString();
document.cookie = 'sortOrder=' + cookieValue + '; SameSite=Strict';
fetchSubscriptions(null, null, "sort");
toggleSortOptions();
}
function convertSvgToPng(file, callback) {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.src = e.target.result;
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const pngDataUrl = canvas.toDataURL('image/png');
const pngFile = dataURLtoFile(pngDataUrl, file.name.replace(".svg", ".png"));
callback(pngFile);
};
};
reader.readAsDataURL(file);
}
function dataURLtoFile(dataurl, filename) {
let arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
function submitFormData(formData, submitButton, endpoint) {
fetch(endpoint, {
method: "POST",
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.status === "Success") {
showSuccessMessage(data.message);
fetchSubscriptions(null, null, "add");
closeAddSubscription();
}
})
.catch((error) => {
showErrorMessage(error);
submitButton.disabled = false;
});
}
document.addEventListener('DOMContentLoaded', function () {
const subscriptionForm = document.querySelector("#subs-form");
const submitButton = document.querySelector("#save-button");
const endpoint = "endpoints/subscription/add.php";
subscriptionForm.addEventListener("submit", function (e) {
e.preventDefault();
submitButton.disabled = true;
const formData = new FormData(subscriptionForm);
const fileInput = document.querySelector("#logo");
const file = fileInput.files[0];
if (file && file.type === "image/svg+xml") {
convertSvgToPng(file, function (pngFile) {
formData.set("logo", pngFile);
submitFormData(formData, submitButton, endpoint);
});
} else {
submitFormData(formData, submitButton, endpoint);
}
});
document.addEventListener('mousedown', function (event) {
const sortOptions = document.querySelector('#sort-options');
const sortButton = document.querySelector("#sort-button");
if (!sortOptions.contains(event.target) && !sortButton.contains(event.target) && isSortOptionsOpen) {
sortOptions.classList.remove('is-open');
isSortOptionsOpen = false;
}
});
document.querySelector('#sort-options').addEventListener('focus', function () {
isSortOptionsOpen = true;
});
});
function searchSubscriptions() {
const searchInput = document.querySelector("#search");
const searchContainer = searchInput.parentElement;
const searchTerm = searchInput.value.trim().toLowerCase();
if (searchTerm.length > 0) {
searchContainer.classList.add("has-text");
} else {
searchContainer.classList.remove("has-text");
}
const subscriptions = document.querySelectorAll(".subscription");
subscriptions.forEach(subscription => {
const name = subscription.getAttribute('data-name').toLowerCase();
if (!name.includes(searchTerm)) {
subscription.parentElement.classList.add("hide");
} else {
subscription.parentElement.classList.remove("hide");
}
});
}
function clearSearch() {
const searchInput = document.querySelector("#search");
searchInput.value = "";
searchSubscriptions();
}
function closeSubMenus() {
var subMenus = document.querySelectorAll('.filtermenu-submenu-content');
subMenus.forEach(subMenu => {
subMenu.classList.remove('is-open');
});
}
function setSwipeElements() {
if (window.mobileNavigation) {
const swipeElements = document.querySelectorAll('.subscription');
swipeElements.forEach((element) => {
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;
let translateX = 0;
const maxTranslateX = element.classList.contains('manual') ? -240 : -180;
element.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
element.style.transition = ''; // Remove transition for smooth dragging
});
element.addEventListener('touchmove', (e) => {
currentX = e.touches[0].clientX;
currentY = e.touches[0].clientY;
const diffX = currentX - startX;
const diffY = currentY - startY;
// Check if the swipe is more horizontal than vertical
if (Math.abs(diffX) > Math.abs(diffY)) {
e.preventDefault(); // Prevent vertical scrolling
// Only update translateX if swiping within allowed range
if (!(translateX === maxTranslateX && diffX < 0)) {
translateX = Math.min(0, Math.max(maxTranslateX, diffX)); // Clamp translateX between -180 and 0
element.style.transform = `translateX(${translateX}px)`;
}
}
});
element.addEventListener('touchend', () => {
// Check the final swipe position to determine snap behavior
if (translateX < maxTranslateX / 2) {
// If more than halfway to the left, snap fully open
translateX = maxTranslateX;
} else {
// If swiped less than halfway left or swiped right, snap back to closed
translateX = 0;
}
element.style.transition = 'transform 0.2s ease'; // Smooth snap effect
element.style.transform = `translateX(${translateX}px)`;
element.style.zIndex = '1';
});
});
}
}
const activeFilters = [];
activeFilters['categories'] = [];
activeFilters['members'] = [];
activeFilters['payments'] = [];
activeFilters['state'] = "";
activeFilters['renewalType'] = "";
document.addEventListener("DOMContentLoaded", function () {
var filtermenu = document.querySelector('#filtermenu-button');
filtermenu.addEventListener('click', function () {
this.parentElement.querySelector('.filtermenu-content').classList.toggle('is-open');
closeSubMenus();
});
document.addEventListener('click', function (e) {
var filtermenuContent = document.querySelector('.filtermenu-content');
if (filtermenuContent.classList.contains('is-open')) {
var subMenus = document.querySelectorAll('.filtermenu-submenu');
var clickedInsideSubmenu = Array.from(subMenus).some(subMenu => subMenu.contains(e.target) || subMenu === e.target);
if (!filtermenu.contains(e.target) && !clickedInsideSubmenu) {
closeSubMenus();
filtermenuContent.classList.remove('is-open');
}
}
});
setSwipeElements();
});
function toggleSubMenu(subMenu) {
var subMenu = document.getElementById("filter-" + subMenu);
if (subMenu.classList.contains("is-open")) {
closeSubMenus();
} else {
closeSubMenus();
subMenu.classList.add("is-open");
}
}
function toggleReplacementSub() {
const checkbox = document.getElementById('inactive');
const replacementSubscription = document.querySelector(`#replacement_subscritpion`);
if (checkbox.checked) {
replacementSubscription.classList.remove("hide");
} else {
replacementSubscription.classList.add("hide");
}
}
document.querySelectorAll('.filter-item').forEach(function (item) {
item.addEventListener('click', function (e) {
const searchInput = document.querySelector("#search");
searchInput.value = "";
if (this.hasAttribute('data-categoryid')) {
const categoryId = this.getAttribute('data-categoryid');
if (activeFilters['categories'].includes(categoryId)) {
const categoryIndex = activeFilters['categories'].indexOf(categoryId);
activeFilters['categories'].splice(categoryIndex, 1);
this.classList.remove('selected');
} else {
activeFilters['categories'].push(categoryId);
this.classList.add('selected');
}
} else if (this.hasAttribute('data-memberid')) {
const memberId = this.getAttribute('data-memberid');
if (activeFilters['members'].includes(memberId)) {
const memberIndex = activeFilters['members'].indexOf(memberId);
activeFilters['members'].splice(memberIndex, 1);
this.classList.remove('selected');
} else {
activeFilters['members'].push(memberId);
this.classList.add('selected');
}
} else if (this.hasAttribute('data-paymentid')) {
const paymentId = this.getAttribute('data-paymentid');
if (activeFilters['payments'].includes(paymentId)) {
const paymentIndex = activeFilters['payments'].indexOf(paymentId);
activeFilters['payments'].splice(paymentIndex, 1);
this.classList.remove('selected');
} else {
activeFilters['payments'].push(paymentId);
this.classList.add('selected');
}
} else if (this.hasAttribute('data-state')) {
const state = this.getAttribute('data-state');
if (activeFilters['state'] === state) {
activeFilters['state'] = "";
this.classList.remove('selected');
} else {
activeFilters['state'] = state;
Array.from(this.parentNode.children).forEach(sibling => {
sibling.classList.remove('selected');
});
this.classList.add('selected');
}
} else if (this.hasAttribute('data-renewaltype')) {
const renewalType = this.getAttribute('data-renewaltype');
if (activeFilters['renewalType'] === renewalType) {
activeFilters['renewalType'] = "";
this.classList.remove('selected');
} else {
activeFilters['renewalType'] = renewalType;
Array.from(this.parentNode.children).forEach(sibling => {
sibling.classList.remove('selected');
});
this.classList.add('selected');
}
}
if (activeFilters['categories'].length > 0 || activeFilters['members'].length > 0 ||
activeFilters['payments'].length > 0 || activeFilters['state'] !== "" ||
activeFilters['renewalType'] !== "") {
document.querySelector('#clear-filters').classList.remove('hide');
} else {
document.querySelector('#clear-filters').classList.add('hide');
}
fetchSubscriptions(null, null, "filter");
});
});
function clearFilters() {
const searchInput = document.querySelector("#search");
searchInput.value = "";
activeFilters['categories'] = [];
activeFilters['members'] = [];
activeFilters['payments'] = [];
activeFilters['state'] = "";
activeFilters['renewalType'] = "";
document.querySelectorAll('.filter-item').forEach(function (item) {
item.classList.remove('selected');
});
document.querySelector('#clear-filters').classList.add('hide');
fetchSubscriptions(null, null, "clearfilters");
}
let currentActions = null;
document.addEventListener('click', function (event) {
// Check if click was outside currentActions
if (currentActions && !currentActions.contains(event.target)) {
// Click was outside currentActions, close currentActions
currentActions.classList.remove('is-open');
currentActions = null;
}
});
function expandActions(event, subscriptionId) {
event.stopPropagation();
event.preventDefault();
const subscriptionDiv = document.querySelector(`.subscription[data-id="${subscriptionId}"]`);
const actions = subscriptionDiv.querySelector('.actions');
// Close all other open actions
const allActions = document.querySelectorAll('.actions.is-open');
allActions.forEach((openAction) => {
if (openAction !== actions) {
openAction.classList.remove('is-open');
}
});
// Toggle the clicked actions
actions.classList.toggle('is-open');
// Update currentActions
if (actions.classList.contains('is-open')) {
currentActions = actions;
} else {
currentActions = null;
}
}
function swipeHintAnimation() {
if (window.mobileNavigation && window.matchMedia('(max-width: 768px)').matches) {
const maxAnimations = 3;
const cookieName = 'swipeHintCount';
let count = parseInt(getCookie(cookieName)) || 0;
if (count < maxAnimations) {
const firstElement = document.querySelector('.subscription');
if (firstElement) {
firstElement.style.transition = 'transform 0.3s ease';
firstElement.style.transform = 'translateX(-80px)';
setTimeout(() => {
firstElement.style.transform = 'translateX(0px)';
firstElement.style.zIndex = '1';
}, 600);
}
count++;
document.cookie = `${cookieName}=${count}; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/; SameSite=Strict`;
}
}
}
function autoFillNextPaymentDate(e) {
e.preventDefault();
const frequencySelect = document.querySelector("#frequency");
const cycleSelect = document.querySelector("#cycle");
const startDate = document.querySelector("#start_date");
const nextPayment = document.querySelector("#next_payment");
// Do nothing if frequency, cycle, or start date is not set
if (!frequencySelect.value || !cycleSelect.value || !startDate.value || isNaN(Date.parse(startDate.value))) {
console.log(frequencySelect.value, cycleSelect.value, startDate.value);
return;
}
const today = new Date();
const cycle = cycleSelect.value;
const frequency = Number(frequencySelect.value);
const nextDate = new Date(startDate.value);
let safetyCounter = 0;
const maxIterations = 1000;
while (nextDate <= today && safetyCounter < maxIterations) {
switch (cycle) {
case '1': // Days
nextDate.setDate(nextDate.getDate() + frequency);
break;
case '2': // Weeks
nextDate.setDate(nextDate.getDate() + 7 * frequency);
break;
case '3': // Months
nextDate.setMonth(nextDate.getMonth() + frequency);
break;
case '4': // Years
nextDate.setFullYear(nextDate.getFullYear() + frequency);
break;
default:
}
safetyCounter++;
}
if (safetyCounter === maxIterations) {
return;
}
nextPayment.value = toISOStringWithTimezone(nextDate).substring(0, 10);
}
function toISOStringWithTimezone(date) {
const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0');
const tzOffset = -date.getTimezoneOffset();
const sign = tzOffset >= 0 ? '+' : '-';
const hoursOffset = pad(tzOffset / 60);
const minutesOffset = pad(tzOffset % 60);
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
sign + hoursOffset +
':' + minutesOffset;
}
window.addEventListener('load', () => {
if (document.querySelector('.subscription')) {
swipeHintAnimation();
}
});

View File

@@ -4,6 +4,7 @@ self.addEventListener('install', function (event) {
const urlsToCache = [
'.',
'index.php',
'subscriptions.php',
'profile.php',
'calendar.php',
'settings.php',
@@ -31,6 +32,7 @@ self.addEventListener('install', function (event) {
'webfonts/fa-regular-400.ttf',
'scripts/common.js',
'scripts/dashboard.js',
'scripts/subscriptions.js',
'scripts/stats.js',
'scripts/settings.js',
'scripts/theme.js',
@@ -39,14 +41,18 @@ self.addEventListener('install', function (event) {
'scripts/login.js',
'scripts/admin.js',
'scripts/calendar.js',
'scripts/i18n/en.js',
'scripts/i18n/cs.js',
'scripts/i18n/da.js',
'scripts/i18n/de.js',
'scripts/i18n/el.js',
'scripts/i18n/en.js',
'scripts/i18n/es.js',
'scripts/i18n/fr.js',
'scripts/i18n/id.js',
'scripts/i18n/it.js',
'scripts/i18n/jp.js',
'scripts/i18n/ko.js',
'scripts/i18n/nl.js',
'scripts/i18n/pl.js',
'scripts/i18n/pt.js',
'scripts/i18n/pt_br.js',
@@ -55,6 +61,7 @@ self.addEventListener('install', function (event) {
'scripts/i18n/sr_lat.js',
'scripts/i18n/sr.js',
'scripts/i18n/tr.js',
'scripts/i18n/uk.js',
'scripts/i18n/vi.js',
'scripts/i18n/zh_cn.js',
'scripts/i18n/zh_tw.js',
@@ -99,11 +106,13 @@ self.addEventListener('install', function (event) {
'images/siteicons/svg/mobile-menu/profile.php',
'images/siteicons/svg/mobile-menu/settings.php',
'images/siteicons/svg/mobile-menu/statistics.php',
'images/siteicons/svg/mobile-menu/subscriptions.php',
'images/siteicons/pwa/stats.png',
'images/siteicons/pwa/settings.png',
'images/siteicons/pwa/about.png',
'images/siteicons/pwa/calendar.png',
'images/siteicons/pwa/subscriptions.png',
'images/siteicons/pwa/dashboard.png',
'images/uploads/icons/paypal.png',
'images/uploads/icons/creditcard.png',
'images/uploads/icons/banktransfer.png',

View File

@@ -901,6 +901,91 @@ $userData['currency_symbol'] = $currencies[$main_currency]['symbol'];
</div>
</section>
<?php
$sql = "SELECT * FROM ai_settings WHERE user_id = :userId LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$aiSettings = [];
if ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$aiSettings = $row;
}
?>
<section class="account-section">
<header>
<h2><?= translate('ai_recommendations', $i18n) ?></h2>
</header>
<div class="account-ai-settings">
<div class="form-group-inline">
<input type="checkbox" id="ai_enabled" name="ai_enabled" <?= isset($aiSettings['enabled']) && $aiSettings['enabled'] ? "checked" : "" ?>>
<label for="ai_enabled" class="capitalize"><?= translate('enabled', $i18n) ?></label>
</div>
<div class="form-group">
<label for="ai_type"><?= translate('provider', $i18n) ?>:</label>
<select id="ai_type" name="ai_type" onchange="toggleAiInputs()">
<option value="chatgpt" <?= (isset($aiSettings['type']) && $aiSettings['type'] == 'chatgpt') ? 'selected' : '' ?>>ChatGPT</option>
<option value="gemini" <?= (isset($aiSettings['type']) && $aiSettings['type'] == 'gemini') ? 'selected' : '' ?>>Gemini</option>
<option value="ollama" <?= (isset($aiSettings['type']) && $aiSettings['type'] == 'ollama') ? 'selected' : '' ?>>Local Ollama</option>
</select>
</div>
<div class="form-group-inline">
<input type="text" id="ai_api_key" name="ai_api_key"
class="<?= (isset($aiSettings['type']) && $aiSettings['type'] == 'ollama') ? 'hidden' : '' ?>"
placeholder="<?= translate('api_key', $i18n) ?>"
value="<?= isset($aiSettings['api_key']) ? htmlspecialchars($aiSettings['api_key']) : '' ?>" />
<input type="text" id="ai_ollama_host" name="ai_ollama_host"
class="<?= (!isset($aiSettings['type']) || $aiSettings['type'] != 'ollama') ? 'hidden' : '' ?>"
placeholder="<?= translate('host', $i18n) ?>"
value="<?= isset($aiSettings['url']) ? htmlspecialchars($aiSettings['url']) : '' ?>" />
<button type="button" id="fetchModelsButton" class="button thin" onclick="fetch_ai_models()">
<?= translate('test', $i18n) ?>
</button>
</div>
<div class="form-group">
<label for="ai_model"><?= translate('ai_model', $i18n) ?>:</label>
<select id="ai_model" name="ai_model">
<option value=""><?= translate('select_ai_model', $i18n) ?></option>
<?php if (!empty($aiSettings['model'])): ?>
<option value="<?= htmlspecialchars($aiSettings['model']) ?>" selected>
<?= htmlspecialchars($aiSettings['model']) ?></option>
<?php endif; ?>
</select>
</div>
<div class="form-group">
<label for="ai_run_schedule" class="flex"><?= translate('run_schedule', $i18n) ?>: <span
class="info-badge"><?= translate("coming_soon", $i18n) ?></span></span></label>
<select id="ai_run_schedule" name="ai_run_schedule" disabled>
<option value="manual" <?= (isset($aiSettings['run_schedule']) && $aiSettings['run_schedule'] == 'manual') ? 'selected' : '' ?>><?= translate('manually', $i18n) ?>
</option>
<option value="weekly" <?= (isset($aiSettings['run_schedule']) && $aiSettings['run_schedule'] == 'weekly') ? 'selected' : '' ?>><?= translate('Weekly', $i18n) ?>
</option>
<option value="monthly" <?= (isset($aiSettings['run_schedule']) && $aiSettings['run_schedule'] == 'monthly') ? 'selected' : '' ?>><?= translate('Monthly', $i18n) ?>
</option>
</select>
</div>
<div class="buttons wrap mobile-reverse">
<?php
$canBeExecuted = !empty($aiSettings['model']) && !empty($aiSettings['enabled']) && $aiSettings['enabled'] == 1;
?>
<input type="button" id="runAiRecommendations"
class="secondary-button thin mobile-grow-force <?= !$canBeExecuted ? 'hidden' : '' ?>"
onclick="runAiRecommendations()" value="<?= translate('generate_recommendations', $i18n) ?>" />
<div id="aiSpinner" class="spinner ai-spinner hidden"></div>
<input type="submit" class="thin mobile-grow-force" value="<?= translate('save', $i18n) ?>"
id="saveAiSettings" onClick="saveAiSettingsButton()" />
</div>
<div class="settings-notes">
<p><i class="fa-solid fa-circle-info"></i><?= translate('ai_recommendations_info', $i18n) ?></p>
<p><i class="fa-solid fa-circle-info"></i><?= translate('may_take_time', $i18n) ?></p>
<p><i class="fa-solid fa-circle-info"></i><?= translate('recommendations_visible_on_dashboard', $i18n) ?></p>
</div>
</div>
</section>
<?php
$sql = "SELECT * FROM payment_methods WHERE user_id = :userId ORDER BY `order` ASC";
$stmt = $db->prepare($sql);
@@ -1163,8 +1248,7 @@ $userData['currency_symbol'] = $currencies[$main_currency]['symbol'];
<div>
<div class="form-group-inline">
<input type="checkbox" id="showoriginalprice" name="showoriginalprice"
onChange="setShowOriginalPrice()" <?php if ($settings['show_original_price'])
echo 'checked'; ?>>
onChange="setShowOriginalPrice()" <?= $settings['show_original_price'] ? 'checked' : '' ?>>
<label for="showoriginalprice"><?= translate('show_original_price', $i18n) ?></label>
</div>
</div>
@@ -1172,8 +1256,7 @@ $userData['currency_symbol'] = $currencies[$main_currency]['symbol'];
<div>
<div class="form-group-inline">
<input type="checkbox" id="mobilenavigation" name="mobilenavigation"
onChange="setMobileNavigation()" <?php if ($settings['mobile_nav'])
echo 'checked'; ?>>
onChange="setMobileNavigation()" <?= $settings['mobile_nav'] ? 'checked' : '' ?>>
<label for="mobilenavigation"><?= translate('use_mobile_navigation_bar', $i18n) ?></label>
</div>
<div class="mobile-nav-image">
@@ -1182,8 +1265,7 @@ $userData['currency_symbol'] = $currencies[$main_currency]['symbol'];
<div>
<div class="form-group-inline">
<input type="checkbox" id="showsubscriptionprogress" name="showsubscriptionprogress"
onChange="setShowSubscriptionProgress()" <?php if ($settings['show_subscription_progress'])
echo 'checked'; ?>>
onChange="setShowSubscriptionProgress()" <?= $settings['show_subscription_progress'] ? 'checked' : '' ?>>
<label for="showsubscriptionprogress"><?= translate('show_subscription_progress', $i18n) ?></label>
</div>
</div>
@@ -1191,16 +1273,15 @@ $userData['currency_symbol'] = $currencies[$main_currency]['symbol'];
<div>
<div class="form-group-inline">
<input type="checkbox" id="disabledtobottom" name="disabledtobottom"
onChange="setDisabledToBottom()" <?php if ($settings['disabled_to_bottom'])
echo 'checked'; ?>>
onChange="setDisabledToBottom()" <?= $settings['disabled_to_bottom'] ? 'checked' : '' ?>>
<label
for="disabledtobottom"><?= translate('show_disabled_subscriptions_at_the_bottom', $i18n) ?></label>
</div>
</div>
<div>
<div class="form-group-inline">
<input type="checkbox" id="hidedisabled" name="hidedisabled" onChange="setHideDisabled()" <?php if ($settings['hide_disabled'])
echo 'checked'; ?>>
<input type="checkbox" id="hidedisabled" name="hidedisabled" onChange="setHideDisabled()"
<?= $settings['hide_disabled'] ? 'checked' : '' ?>>
<label for="hidedisabled"><?= translate('hide_disabled_subscriptions', $i18n) ?></label>
</div>
</div>
@@ -1215,8 +1296,7 @@ $userData['currency_symbol'] = $currencies[$main_currency]['symbol'];
<div>
<div class="form-group-inline">
<input type="checkbox" id="removebackground" name="removebackground"
onChange="setRemoveBackground()" <?php if ($settings['remove_background'])
echo 'checked'; ?>>
onChange="setRemoveBackground()" <?= $settings['remove_background'] ? 'checked' : '' ?>>
<label for="removebackground"><?= translate('remove_background', $i18n) ?></label>
</div>
</div>

278
stats.php
View File

@@ -1,82 +1,6 @@
<?php
require_once 'includes/header.php';
function getPricePerMonth($cycle, $frequency, $price)
{
switch ($cycle) {
case 1:
$numberOfPaymentsPerMonth = (30 / $frequency);
return $price * $numberOfPaymentsPerMonth;
case 2:
$numberOfPaymentsPerMonth = (4.35 / $frequency);
return $price * $numberOfPaymentsPerMonth;
case 3:
$numberOfPaymentsPerMonth = (1 / $frequency);
return $price * $numberOfPaymentsPerMonth;
case 4:
$numberOfMonths = (12 * $frequency);
return $price / $numberOfMonths;
}
}
function getPriceConverted($price, $currency, $database, $userId)
{
$query = "SELECT rate FROM currencies WHERE id = :currency AND user_id = :userId";
$stmt = $database->prepare($query);
$stmt->bindParam(':currency', $currency, SQLITE3_INTEGER);
$stmt->bindParam(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$exchangeRate = $result->fetchArray(SQLITE3_ASSOC);
if ($exchangeRate === false) {
return $price;
} else {
$fromRate = $exchangeRate['rate'];
return $price / $fromRate;
}
}
//Get household members
$members = array();
$query = "SELECT * FROM household WHERE user_id = :userId";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$memberId = $row['id'];
$members[$memberId] = $row;
$members[$memberId]['count'] = 0;
$memberCost[$memberId]['cost'] = 0;
$memberCost[$memberId]['name'] = $row['name'];
}
// Get categories
$categories = array();
$query = "SELECT * FROM categories WHERE user_id = :userId ORDER BY 'order' ASC";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$categoryId = $row['id'];
$categories[$categoryId] = $row;
$categories[$categoryId]['count'] = 0;
$categoryCost[$categoryId]['cost'] = 0;
$categoryCost[$categoryId]['name'] = $row['name'];
}
// Get payment methods
$paymentMethods = array();
$query = "SELECT * FROM payment_methods WHERE user_id = :userId AND enabled = 1";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$paymentMethodId = $row['id'];
$paymentMethods[$paymentMethodId] = $row;
$paymentMethods[$paymentMethodId]['count'] = 0;
$paymentMethodsCount[$paymentMethodId]['count'] = 0;
$paymentMethodsCount[$paymentMethodId]['name'] = $row['name'];
}
// Get code of main currency to display on statistics
$query = "SELECT c.code
@@ -89,207 +13,7 @@ $result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$code = $row['code'];
$activeSubscriptions = 0;
$inactiveSubscriptions = 0;
// Calculate total monthly price
$mostExpensiveSubscription = array();
$mostExpensiveSubscription['price'] = 0;
$amountDueThisMonth = 0;
$totalCostPerMonth = 0;
$totalSavingsPerMonth = 0;
$totalCostsInReplacementsPerMonth = 0;
$statsSubtitleParts = [];
$query = "SELECT name, price, logo, frequency, cycle, currency_id, next_payment, payer_user_id, category_id, payment_method_id, inactive, replacement_subscription_id FROM subscriptions";
$conditions = [];
$params = [];
if (isset($_GET['member'])) {
$conditions[] = "payer_user_id = :member";
$params[':member'] = $_GET['member'];
$statsSubtitleParts[] = $members[$_GET['member']]['name'];
}
if (isset($_GET['category'])) {
$conditions[] = "category_id = :category";
$params[':category'] = $_GET['category'];
$statsSubtitleParts[] = $categories[$_GET['category']]['name'] == "No category" ? translate("no_category", $i18n) : $categories[$_GET['category']]['name'];
}
if (isset($_GET['payment'])) {
$conditions[] = "payment_method_id = :payment";
$params[':payment'] = $_GET['payment'];
$statsSubtitleParts[] = $paymentMethodsCount[$_GET['payment']]['name'];
}
$conditions[] = "user_id = :userId";
$params[':userId'] = $userId;
if (!empty($conditions)) {
$query .= " WHERE " . implode(' AND ', $conditions);
}
$stmt = $db->prepare($query);
$statsSubtitle = !empty($statsSubtitleParts) ? '(' . implode(', ', $statsSubtitleParts) . ')' : "";
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, SQLITE3_INTEGER);
}
$result = $stmt->execute();
$usesMultipleCurrencies = false;
if ($result) {
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$subscriptions[] = $row;
}
if (isset($subscriptions)) {
$replacementSubscriptions = array();
foreach ($subscriptions as $subscription) {
$name = $subscription['name'];
$price = $subscription['price'];
$logo = $subscription['logo'];
$frequency = $subscription['frequency'];
$cycle = $subscription['cycle'];
$currency = $subscription['currency_id'];
if ($currency != $userData['main_currency']) {
$usesMultipleCurrencies = true;
}
$next_payment = $subscription['next_payment'];
$payerId = $subscription['payer_user_id'];
$members[$payerId]['count'] += 1;
$categoryId = $subscription['category_id'];
$categories[$categoryId]['count'] += 1;
$paymentMethodId = $subscription['payment_method_id'];
$paymentMethods[$paymentMethodId]['count'] += 1;
$inactive = $subscription['inactive'];
$replacementSubscriptionId = $subscription['replacement_subscription_id'];
$originalSubscriptionPrice = getPriceConverted($price, $currency, $db, $userId);
$price = getPricePerMonth($cycle, $frequency, $originalSubscriptionPrice);
if ($inactive == 0) {
$activeSubscriptions++;
$totalCostPerMonth += $price;
$memberCost[$payerId]['cost'] += $price;
$categoryCost[$categoryId]['cost'] += $price;
$paymentMethodsCount[$paymentMethodId]['count'] += 1;
if ($price > $mostExpensiveSubscription['price']) {
$mostExpensiveSubscription['price'] = $price;
$mostExpensiveSubscription['name'] = $name;
$mostExpensiveSubscription['logo'] = $logo;
}
// Calculate ammount due this month
$nextPaymentDate = DateTime::createFromFormat('Y-m-d', trim($next_payment));
$tomorrow = new DateTime('tomorrow');
$endOfMonth = new DateTime('last day of this month');
if ($nextPaymentDate >= $tomorrow && $nextPaymentDate <= $endOfMonth) {
$timesToPay = 1;
$daysInMonth = $endOfMonth->diff($tomorrow)->days + 1;
$daysRemaining = $endOfMonth->diff($nextPaymentDate)->days + 1;
if ($cycle == 1) {
$timesToPay = $daysRemaining / $frequency;
}
if ($cycle == 2) {
$weeksInMonth = ceil($daysInMonth / 7);
$weeksRemaining = ceil($daysRemaining / 7);
$timesToPay = $weeksRemaining / $frequency;
}
$amountDueThisMonth += $originalSubscriptionPrice * $timesToPay;
}
} else {
$inactiveSubscriptions++;
$totalSavingsPerMonth += $price;
// Check if it has a replacement subscription and if it was not already counted
if ($replacementSubscriptionId && !in_array($replacementSubscriptionId, $replacementSubscriptions)) {
$query = "SELECT price, currency_id, cycle, frequency FROM subscriptions WHERE id = :replacementSubscriptionId";
$stmt = $db->prepare($query);
$stmt->bindValue(':replacementSubscriptionId', $replacementSubscriptionId, SQLITE3_INTEGER);
$result = $stmt->execute();
$replacementSubscription = $result->fetchArray(SQLITE3_ASSOC);
if ($replacementSubscription) {
$replacementSubscriptionPrice = getPriceConverted($replacementSubscription['price'], $replacementSubscription['currency_id'], $db, $userId);
$replacementSubscriptionPrice = getPricePerMonth($replacementSubscription['cycle'], $replacementSubscription['frequency'], $replacementSubscriptionPrice);
$totalCostsInReplacementsPerMonth += $replacementSubscriptionPrice;
}
}
$replacementSubscriptions[] = $replacementSubscriptionId;
}
}
// Subtract the total cost of replacement subscriptions from the total savings
$totalSavingsPerMonth -= $totalCostsInReplacementsPerMonth;
// Calculate yearly price
$totalCostPerYear = $totalCostPerMonth * 12;
// Calculate average subscription monthly cost
if ($activeSubscriptions > 0) {
$averageSubscriptionCost = $totalCostPerMonth / $activeSubscriptions;
} else {
$totalCostPerYear = 0;
$averageSubscriptionCost = 0;
}
} else {
$totalCostPerYear = 0;
$averageSubscriptionCost = 0;
}
}
$showVsBudgetGraph = false;
$vsBudgetDataPoints = [];
if (isset($userData['budget']) && $userData['budget'] > 0) {
$budget = $userData['budget'];
$budgetLeft = $budget - $totalCostPerMonth;
$budgetLeft = $budgetLeft < 0 ? 0 : $budgetLeft;
$budgetUsed = ($totalCostPerMonth / $budget) * 100;
$budgetUsed = $budgetUsed > 100 ? 100 : $budgetUsed;
if ($totalCostPerMonth > $budget) {
$overBudgetAmount = $totalCostPerMonth - $budget;
}
$showVsBudgetGraph = true;
$vsBudgetDataPoints = [
[
"label" => translate('budget_remaining', $i18n),
"y" => $budgetLeft,
],
[
"label" => translate('total_cost', $i18n),
"y" => $totalCostPerMonth,
],
];
}
$showCantConverErrorMessage = false;
if ($usesMultipleCurrencies) {
$query = "SELECT api_key FROM fixer WHERE user_id = :userId";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
if ($result->fetchArray(SQLITE3_ASSOC) === false) {
$showCantConverErrorMessage = true;
}
}
$query = "SELECT * FROM total_yearly_cost WHERE user_id = :userId";
$stmt = $db->prepare($query);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();
$totalMonthlyCostDataPoints = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$totalMonthlyCostDataPoints[] = [
"label" => html_entity_decode($row['date']),
"y" => round($row['cost'] / 12, 2),
];
}
$showTotalMonthlyCostGraph = count($totalMonthlyCostDataPoints) > 1;
require_once 'includes/stats_calculations.php';
?>
<section class="contain">

View File

@@ -1,42 +1,45 @@
:root {
--background-color: #303030;
--background-color-rgb: 48, 48, 48;
--box-background-color: #222222;
--box-background-color-rgb: 34, 34, 34;
--box-border-color: #333;
--box-border-color-rgb: 51, 51, 51;
--header-background-color: #222222;
--header-background-color-rgb: 34, 34, 34;
--text-color: #E0E0E0;
--text-color-rgb: 224, 224, 224;
--input-border-color: #666;
--input-border-color-rgb: 102, 102, 102;
--input-background-color: #555;
--input-background-color-rgb: 85, 85, 85;
--input-disabled-background-color: #999999;
--input-disabled-background-color-rgb: 153, 153, 153;
--input-disabled-border-color: #666666;
--input-disabled-border-color-rgb: 102, 102, 102;
--box-shadow: 0 2px 5px rgba(120, 120, 120, 0.1);
--negative-box-shadow: 0 -2px 5px rgba(120, 120, 120, 0.1);
}
body {
background-color: #303030;
color: #E0E0E0;
background-color: var(--background-color);
color: var(--text-color);
}
body>header {
background-color: #222;
background-color: var(--header-background-color);
}
svg .text-color {
fill: #E0E0E0;
fill: var(--text-color);
}
.split-header>h2 .header-subtitle {
color: #A9A9A9;
}
.subscription,
.subscription-form,
.subscription-modal,
.account-section,
.avatar-select,
.logo-search,
.icon-search,
.dropdown-content,
.sort-options,
.statistic,
.graph,
.filtermenu-content,
.subscription-main .actions,
.calendar,
.totp-popup {
background-color: #222;
border: 1px solid #333;
box-shadow: 0 2px 5px rgba(120, 120, 120, 0.1);
color: var(--text-color);
}
.dropbtn {
color: #E0E0E0E0;
color: #E0E0E0;
}
.dropdown-content a {
@@ -88,18 +91,6 @@ svg .text-color {
color: #EEE;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="date"],
input[type="number"],
textarea,
select {
background-color: #555;
border: 1px solid #666;
color: #E0E0E0;
}
input[type="text"]::placeholder,
input[type="email"]::placeholder,
input[type="password"]::placeholder,
@@ -110,18 +101,6 @@ select::placeholder {
color: #BBB;
}
input[type="text"]:disabled,
input[type="email"]:disabled,
input[type="password"]:disabled,
input[type="date"]:disabled,
input[type="number"]:disabled,
textarea:disabled,
select:disabled {
background-color: #999;
border-color: #666;
cursor: not-allowed;
}
button.secondary-button,
button.button.secondary-button,
input[type="button"].secondary-button {
@@ -161,8 +140,6 @@ input[type="color"] {
}
.toast {
border: 1px solid #333;
background: #222;
box-shadow: 0 6px 20px -5px rgba(255, 255, 255, 0.1);
}
@@ -260,12 +237,6 @@ input {
}
@media (max-width: 768px) {
.mobile-nav {
background-color: #222;
border-top: #111;
box-shadow: 0 -2px 5px rgba(120, 120, 120, 0.1);
}
.mobile-nav>a {
color: #909090;
}

View File

@@ -173,7 +173,7 @@ input[type="checkbox"] {
.or-separator {
text-align: center;
display: block;
margin: 3px 0px 7px;
margin: 3px 0px 16px;
font-size: 16px;
}

View File

@@ -6,8 +6,8 @@ body {
font-family: Barlow, 'Helvetica Neue', Helvetica, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
color: #202020;
background-color: var(--background-color);
color: var(--text-color);
}
body.no-scroll {
@@ -28,6 +28,10 @@ textarea {
font-weight: 400;
}
input.hidden {
display: none;
}
@media (max-width: 768px) {
body.no-scroll section.contain {
display: none;
@@ -97,7 +101,7 @@ h3 {
body>header {
border-bottom: 7px solid var(--main-color);
background-color: white;
background-color: var(--header-background-color);
}
body>header>.contain {
@@ -145,7 +149,7 @@ header .logo .logo-image svg {
align-items: center;
gap: 8px;
background-color: transparent;
color: #202020;
color: var(--text-color);
padding: 7px 12px;
font-size: 16px;
border: none;
@@ -165,10 +169,10 @@ header .logo .logo-image svg {
display: none;
position: absolute;
right: 0px;
background-color: #fff;
border: 1px solid #eee;
background-color: var(--header-background-color);
border: 1px solid var(--box-border-color);
min-width: 130px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
z-index: 5;
width: max-content;
border-top: none;
@@ -176,7 +180,7 @@ header .logo .logo-image svg {
}
.dropdown-content a {
color: black;
color: var(--text-color);
padding: 14px 18px;
text-decoration: none;
display: flex;
@@ -345,10 +349,9 @@ button:hover svg .main-color {
.subscription-container {
position: relative;
background-color: #FFFFFF;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: var(--box-background-color);
box-shadow: var(--box-shadow);
border-radius: 16px;
background-color: #FFFFFF;
}
.subscription-container>.mobile-actions {
@@ -405,8 +408,8 @@ button.mobile-action-renew {
height: auto;
justify-content: flex-start;
gap: 12px;
background-color: #FFFFFF;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: var(--box-background-color);
box-shadow: var(--box-shadow);
padding: 12px 15px;
border-radius: 16px;
cursor: pointer;
@@ -437,7 +440,7 @@ button.mobile-action-renew {
}
.subscription.inactive {
background-color: #FFF;
background-color: var(--box-background-color);
color: rgba(100, 100, 100, 0.6);
box-shadow: 0 2px 5px rgba(100, 100, 100, 0.1);
}
@@ -479,10 +482,10 @@ button.mobile-action-renew {
top: 60px;
z-index: 2;
flex-direction: column;
color: #202020;
background-color: #FFFFFF;
border: 1px solid #eee;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
color: var(--text-color);
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
box-shadow: var(--box-shadow);
border-radius: 16px;
padding: 0px;
margin: 0px;
@@ -504,7 +507,7 @@ button.mobile-action-renew {
padding: 14px 35px 14px 18px;
gap: 12px;
cursor: pointer;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--box-border-color);
}
.rtl .subscription-main .actions>li {
@@ -752,10 +755,10 @@ button.mobile-action-renew {
}
.account-section {
background-color: #FFFFFF;
border: 1px solid #eee;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
padding: 20px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
border-radius: 16px;
}
@@ -860,14 +863,14 @@ header #avatar {
.avatar-select {
display: none;
background-color: white;
border: 1px solid #eee;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
position: absolute;
padding: 20px;
box-sizing: border-box;
width: 336px;
max-width: 100%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
z-index: 3;
}
@@ -936,7 +939,7 @@ header #avatar {
position: absolute;
top: -4px;
right: -11px;
background-color: #FFF;
background-color: var(--box-background-color);
border-radius: 50%;
cursor: pointer;
display: flex;
@@ -962,6 +965,7 @@ header #avatar {
.account-members .buttons,
.account-currencies .buttons,
.account-fixer .buttons,
.account-ai-settings .buttons,
.account-categories .buttons,
.account-notifications .buttons,
.admin-form .buttons,
@@ -1123,7 +1127,7 @@ header #avatar {
.payments-list .payments-payment .delete-payment-method {
padding: 5px;
font-weight: bold;
color: #202020;
color: var(--text-color);
}
.credits-list {
@@ -1302,11 +1306,11 @@ select {
padding: 0px 15px;
height: 50px;
font-size: 16px;
background-color: #FFFFFF;
border: 1px solid #ccc;
background-color: var(--input-background-color);
border: 1px solid var(--input-border-color);
border-radius: 8px;
outline: none;
color: #202020;
color: var(--text-color);
box-sizing: border-box;
}
@@ -1361,7 +1365,7 @@ button.button {
padding: 15px 30px;
font-size: 16px;
background-color: var(--main-color);
color: #fff;
color: var(--text-color-inverted);
border: none;
border-radius: 8px;
cursor: pointer;
@@ -1423,8 +1427,8 @@ input[type="checkbox"] {
height: 25px;
padding: 0px;
margin: 0px;
background-color: #fff;
border: 1px solid #ccc;
background-color: var(--input-background-color);
border: 1px solid var(--input-border-color);
border-radius: 8px;
display: grid;
place-content: center;
@@ -1437,18 +1441,18 @@ button.disabled {
input[type="text"]:disabled,
input[type="password"]:disabled,
input[type="email"]:disabled {
background-color: #f5f5f5;
border-color: #f5f5f5;
background-color: var(--input-disabled-background-color);
border-color: var(--input-disabled-border-color);
cursor: not-allowed;
}
textarea {
font-size: 16px;
background-color: #FFFFFF;
border: 1px solid #ccc;
background-color: var(--input-background-color);
border: 1px solid var(--input-border-color);
border-radius: 8px;
padding: 5px 14px;
color: #202020;
color: var(--text-color);
width: 100%;
height: 245px;
}
@@ -1483,10 +1487,10 @@ textarea.thin {
overflow-y: auto;
overflow-x: hidden;
padding: 10px;
border: 1px solid #EEEEEE;
border: 1px solid var(--box-border-color);
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: white;
box-shadow: var(--box-shadow);
background-color: var(--box-background-color);
box-sizing: border-box;
z-index: 1;
display: none;
@@ -1557,7 +1561,7 @@ button.dark-theme-button {
border-radius: 8px;
padding: 16px 20px 14px;
background-color: transparent;
color: #202020;
color: var(--text-color);
cursor: pointer;
flex-grow: 1;
}
@@ -1596,11 +1600,11 @@ button.dark-theme-button i {
.subscription-form,
.subscription-modal {
background-color: #FFFFFF;
background-color: var(--box-background-color);
padding: 22px;
border: 1px solid #EEEEEE;
border: 1px solid var(--box-border-color);
border-radius: 16px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
box-sizing: border-box;
position: fixed;
left: 50%;
@@ -1736,12 +1740,12 @@ button.dark-theme-button i {
.sort-options {
position: absolute;
color: #202020;
color: var(--text-color);
font-size: 16px;
background-color: #FFFFFF;
border: 1px solid #EEEEEE;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 5px rgba(163, 100, 100, 0.1);
box-sizing: border-box;
top: 52px;
right: 0px;
@@ -1824,8 +1828,8 @@ button.dark-theme-button i {
bottom: 25px;
right: 30px;
border-radius: 12px;
border: 1px solid #eeeeee;
background: #fff;
border: 1px solid var(--box-border-color);
background-color: var(--box-background-color);
padding: 20px 35px 20px 25px;
box-shadow: 0 6px 20px -5px rgba(0, 0, 0, 0.1);
overflow: hidden;
@@ -1954,10 +1958,10 @@ button.dark-theme-button i {
}
.statistic {
background-color: #FFFFFF;
border: 1px solid #EEEEEE;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 16px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
padding: 20px 24px 30px;
display: flex;
flex-direction: column;
@@ -2021,10 +2025,10 @@ button.dark-theme-button i {
}
.graph {
background-color: #FFFFFF;
border: 1px solid #EEEEEE;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 16px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
flex-basis: 48%;
align-items: center;
justify-content: center;
@@ -2097,14 +2101,13 @@ button.dark-theme-button i {
.filtermenu-content {
display: none;
position: absolute;
background-color: #f9f9f9;
left: auto;
right: 0;
width: 220px;
background-color: #fff;
border: 1px solid #eee;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
z-index: 3;
overflow: hidden;
margin-top: 6px;
@@ -2325,7 +2328,7 @@ button.dark-theme-button i {
right: -5px;
border: 1px solid;
border-radius: 15px;
background-color: white;
background-color: var(--box-background-color);
padding: 4px;
}
@@ -2404,10 +2407,10 @@ button.dark-theme-button i {
display: flex;
flex-direction: column;
width: 100%;
background-color: #FFFFFF;
background-color: var(--box-background-color);
border-collapse: collapse;
border-radius: 16px;
box-shadow: 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
box-sizing: border-box;
}
@@ -2452,7 +2455,7 @@ button.dark-theme-button i {
text-align: center;
box-sizing: border-box;
min-height: 92px;
border-right: 1px solid #EEE;
border-right: 1px solid var(--box-border-color);
}
.calendar .calendar-body .calendar-cell:last-of-type {
@@ -2543,6 +2546,16 @@ button.dark-theme-button i {
.mobile-grow {
flex-grow: 1;
}
.mobile-reverse {
flex-direction: column-reverse;
}
.mobile-grow-force {
flex-grow: 1;
width: 100%;
}
}
.bold {
@@ -2686,7 +2699,7 @@ input[type="radio"]:checked+label::after {
border-radius: 12px;
margin-bottom: 20px;
text-align: center;
color: #222;
color: var(--text-color);
}
.update-banner>span {
@@ -2694,7 +2707,7 @@ input[type="radio"]:checked+label::after {
}
.update-banner>span>a {
color: #222;
color: var(--text-color);
text-decoration: underline;
}
@@ -2715,10 +2728,10 @@ input[type="radio"]:checked+label::after {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #FFFFFF;
border: 1px solid #EEEEEE;
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 16px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--box-shadow);
box-sizing: border-box;
padding: 20px;
flex-direction: column;
@@ -2806,14 +2819,14 @@ input[type="radio"]:checked+label::after {
position: fixed;
bottom: 0px;
width: 100%;
background-color: #FFFFFF;
border-top: 1px solid #EEEEEE;
background-color: var(--box-background-color);
border-top: 1px solid var(--box-border-color);
display: flex;
flex-direction: row;
justify-content: space-around;
z-index: 2;
padding: 7px 0px;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--negative-box-shadow);
box-sizing: border-box;
align-items: center;
}
@@ -2848,8 +2861,8 @@ input[type="radio"]:checked+label::after {
}
.button.autofill-next-payment {
padding: 15px 15px !important;
margin-top: 22px;
padding: 15px 15px !important;
margin-top: 22px;
}
.autofill-next-payment {
@@ -2870,4 +2883,205 @@ input[type="radio"]:checked+label::after {
.autofill-next-payment.hideOnDesktop {
display: block;
}
}
.dashboard h1 {
margin: 0px;
}
.dashboard-subscriptions-container {
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
padding-bottom: 10px;
}
.dashboard-subscriptions-list {
display: flex;
flex-direction: row;
gap: 10px;
min-width: fit-content;
/* prevent collapsing */
}
.dashboard-subscriptions-list>.subscription-item {
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 16px;
box-shadow: var(--box-shadow);
padding: 20px;
width: 155px;
height: 145px;
flex: 0 0 auto;
/* prevent flex resizing */
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.dashboard-subscriptions-list>.subscription-item.thin {
height: 115px;
}
.dashboard-subscriptions-list>.subscription-item .subscription-item-title {
font-size: 19px;
font-weight: 500;
margin: 0px;
display: -webkit-box;
-webkit-line-clamp: 2; /* maximum number of lines */
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-subscriptions-list>.subscription-item.thin .subscription-item-title {
font-size: 16px;
}
.dashboard-subscriptions-list>.subscription-item .subscription-item-logo {
max-width: 100%;
height: 42px;
object-fit: contain;
}
.dashboard-subscriptions-list>.subscription-item .subscription-item-date {
font-size: 16px;
margin: 0px;
}
.dashboard-subscriptions-list>.subscription-item .subscription-item-price {
font-size: 18px;
font-weight: 600;
margin: 0px;
}
.dashboard-subscriptions-list>.subscription-item .subscription-item-value {
font-size: 24px;
font-weight: 600;
margin: 0px;
}
.dashboard-subscriptions-list>.subscription-item.thin .subscription-item-value {
font-size: 20px;;
}
.ai-recommendations-container {
width: 100%;
box-sizing: border-box;
}
.ai-recommendations-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-recommendation-item {
background-color: var(--box-background-color);
border: 1px solid var(--box-border-color);
border-radius: 16px;
box-shadow: var(--box-shadow);
padding: 18px 20px;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
cursor: pointer;
}
.ai-recommendation-item .ai-recommendation-header h3 {
font-size: 18px;
font-weight: 600;
margin: 0px;
line-height: 1.2;
}
.ai-recommendation-item .ai-recommendation-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-recommendation-item .ai-recommendation-header .item-arrow-down {
color: var(--main-color);
}
.ai-recommendation-item.expanded .ai-recommendation-header .item-arrow-down {
transform: rotate(180deg);
}
.ai-recommendation-item .ai-recommendation-header h3 > span {
color: var(--main-color);
}
.ai-recommendation-item p {
display: none;
font-size: 15px;
margin: 0 0 4px 0;
line-height: 1.5;
margin-top: 8px;
}
.ai-recommendation-item.expanded p {
display: block;
}
.ai-recommendation-item p:last-child {
font-size: 16px;
font-weight: 600;
color: var(--accent-color);
}
.flex {
display: flex;
}
.info-badge {
background-color: orange;
border-radius: 5px;
font-size: 10px;
padding: 2px 6px;
color: #FFFFFF;
margin-bottom: auto;
margin-left: 10px;
}
.spinner {
width: 38px;
height: 38px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--main-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: auto;
}
.spinner.ai-spinner {
margin: 0px 0px 0px auto;
}
.spinner.hidden {
display: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.spinner.ai-spinner {
margin: auto;
}
}

View File

@@ -1,14 +1,36 @@
:root {
--main-color: #007bff;
--main-color: #007BFF;
--main-color-rgb: 0, 123, 255;
--accent-color: #8fbffa;
--accent-color: #8FBFFA;
--accent-color-rgb: 143, 191, 250;
--hover-color: #0056b3;
--hover-color: #0056B3;
--hover-color-rgb: 0, 86, 179;
--error-color: #f45a40;
--error-color: #F45A40;
--error-color-rgb: 244, 90, 64;
--success-color: #188823;
--success-color-rgb: 24, 136, 35;
--background-color: #F5F5F5;
--background-color-rgb: 245, 245, 245;
--header-background-color: #FFFFFF;
--header-background-color-rgb: 255, 255, 255;
--box-background-color: #FFFFFF;
--box-background-color-rgb: 255, 255, 255;
--box-border-color: #EEEEEE;
--box-border-color-rgb: 238, 238, 238;
--box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
--text-color: #202020;
--text-color-rgb: 32, 32, 32;
--text-color-inverted: #FFFFFF;
--text-color-inverted-rgb: 255, 255, 255;
--input-background-color: #FFFFFF;
--input-background-color-rgb: 255, 255, 255;
--input-border-color: #CCCCCC;
--input-border-color-rgb: 204, 204, 204;
--input-disabled-background-color: #F5F5F5;
--input-disabled-background-color-rgb: 245, 245, 245;
--input-disabled-border-color: #F5F5F5;
--input-disabled-border-color-rgb: 245, 245, 245;
--negative-box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
}
svg .main-color {

548
subscriptions.php Normal file
View File

@@ -0,0 +1,548 @@
<?php
require_once 'includes/header.php';
require_once 'includes/getdbkeys.php';
include_once 'includes/list_subscriptions.php';
$sort = "next_payment";
$sortOrder = $sort;
if ($settings['disabledToBottom'] === 'true') {
$sql = "SELECT * FROM subscriptions WHERE user_id = :userId ORDER BY inactive ASC, next_payment ASC";
} else {
$sql = "SELECT * FROM subscriptions WHERE user_id = :userId ORDER BY next_payment ASC, inactive ASC";
}
$params = array();
if (isset($_COOKIE['sortOrder']) && $_COOKIE['sortOrder'] != "") {
$sort = $_COOKIE['sortOrder'] ?? 'next_payment';
}
$sortOrder = $sort;
$allowedSortCriteria = ['name', 'id', 'next_payment', 'price', 'payer_user_id', 'category_id', 'payment_method_id', 'inactive', 'alphanumeric', 'renewal_type'];
$order = ($sort == "price" || $sort == "id") ? "DESC" : "ASC";
if ($sort == "alphanumeric") {
$sort = "name";
}
if (!in_array($sort, $allowedSortCriteria)) {
$sort = "next_payment";
}
if ($sort == "renewal_type") {
$sort = "auto_renew";
}
$sql = "SELECT * FROM subscriptions WHERE user_id = :userId";
if (isset($_GET['member'])) {
$memberIds = explode(',', $_GET['member']);
$placeholders = array_map(function ($key) {
return ":member{$key}";
}, array_keys($memberIds));
$sql .= " AND payer_user_id IN (" . implode(',', $placeholders) . ")";
foreach ($memberIds as $key => $memberId) {
$params[":member{$key}"] = $memberId;
}
}
if (isset($_GET['category'])) {
$categoryIds = explode(',', $_GET['category']);
$placeholders = array_map(function ($key) {
return ":category{$key}";
}, array_keys($categoryIds));
$sql .= " AND category_id IN (" . implode(',', $placeholders) . ")";
foreach ($categoryIds as $key => $categoryId) {
$params[":category{$key}"] = $categoryId;
}
}
if (isset($_GET['payment'])) {
$paymentIds = explode(',', $_GET['payment']);
$placeholders = array_map(function ($key) {
return ":payment{$key}";
}, array_keys($paymentIds));
$sql .= " AND payment_method_id IN (" . implode(',', $placeholders) . ")";
foreach ($paymentIds as $key => $paymentId) {
$params[":payment{$key}"] = $paymentId;
}
}
if (!isset($settings['hideDisabledSubscriptions']) || $settings['hideDisabledSubscriptions'] !== 'true') {
if (isset($_GET['state']) && $_GET['state'] != "") {
$sql .= " AND inactive = :inactive";
$params[':inactive'] = $_GET['state'];
}
}
$orderByClauses = [];
if ($settings['disabledToBottom'] === 'true') {
if (in_array($sort, ["payer_user_id", "category_id", "payment_method_id"])) {
$orderByClauses[] = "$sort $order";
$orderByClauses[] = "inactive ASC";
} else {
$orderByClauses[] = "inactive ASC";
$orderByClauses[] = "$sort $order";
}
} else {
$orderByClauses[] = "$sort $order";
if ($sort != "inactive") {
$orderByClauses[] = "inactive ASC";
}
}
if ($sort != "next_payment") {
$orderByClauses[] = "next_payment ASC";
}
$sql .= " ORDER BY " . implode(", ", $orderByClauses);
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
if (!empty($params)) {
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, SQLITE3_INTEGER);
}
}
$result = $stmt->execute();
if ($result) {
$subscriptions = array();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$subscriptions[] = $row;
}
}
foreach ($subscriptions as $subscription) {
$memberId = $subscription['payer_user_id'];
$members[$memberId]['count']++;
$categoryId = $subscription['category_id'];
$categories[$categoryId]['count']++;
$paymentMethodId = $subscription['payment_method_id'];
$payment_methods[$paymentMethodId]['count']++;
}
if ($sortOrder == "category_id") {
usort($subscriptions, function ($a, $b) use ($categories) {
return $categories[$a['category_id']]['order'] - $categories[$b['category_id']]['order'];
});
}
if ($sortOrder == "payment_method_id") {
usort($subscriptions, function ($a, $b) use ($payment_methods) {
return $payment_methods[$a['payment_method_id']]['order'] - $payment_methods[$b['payment_method_id']]['order'];
});
}
$headerClass = count($subscriptions) > 0 ? "main-actions" : "main-actions hidden";
?>
<style>
.logo-preview:after {
content: '<?= translate('upload_logo', $i18n) ?>';
}
</style>
<section class="contain">
<?php
if ($isAdmin && $settings['update_notification']) {
if (!is_null($settings['latest_version'])) {
$latestVersion = $settings['latest_version'];
if (version_compare($version, $latestVersion) == -1) {
?>
<div class="update-banner">
<?= translate('new_version_available', $i18n) ?>:
<span><a href="https://github.com/ellite/Wallos/releases/tag/<?= htmlspecialchars($latestVersion) ?>"
target="_blank" rel="noreferer">
<?= htmlspecialchars($latestVersion) ?>
</a></span>
</div>
<?php
}
}
}
if ($demoMode) {
?>
<div class="demo-banner">
Running in <b>Demo Mode</b>, certain actions and settings are disabled.<br>
The database will be reset every 120 minutes.
</div>
<?php
}
?>
<header class="<?= $headerClass ?>" id="main-actions">
<button class="button" onClick="addSubscription()">
<i class="fa-solid fa-circle-plus"></i>
<?= translate('new_subscription', $i18n) ?>
</button>
<div class="top-actions">
<div class="search">
<input type="text" autocomplete="off" name="search" id="search" placeholder="<?= translate('search', $i18n) ?>"
onkeyup="searchSubscriptions()" />
<span class="fa-solid fa-magnifying-glass search-icon"></span>
<span class="fa-solid fa-xmark clear-search" onClick="clearSearch()"></span>
</div>
<div class="filtermenu on-dashboard">
<button class="button secondary-button" id="filtermenu-button" title="<?= translate("filter", $i18n) ?>">
<i class="fa-solid fa-filter"></i>
</button>
<?php include 'includes/filters_menu.php'; ?>
</div>
<div class="sort-container">
<button class="button secondary-button" value="Sort" onClick="toggleSortOptions()" id="sort-button"
title="<?= translate('sort', $i18n) ?>">
<i class="fa-solid fa-arrow-down-wide-short"></i>
</button>
<?php include 'includes/sort_options.php'; ?>
</div>
</div>
</header>
<div class="subscriptions" id="subscriptions">
<?php
$formatter = new IntlDateFormatter(
'en', // Force English locale
IntlDateFormatter::SHORT,
IntlDateFormatter::NONE,
null,
null,
'MMM d, yyyy'
);
foreach ($subscriptions as $subscription) {
if ($subscription['inactive'] == 1 && isset($settings['hideDisabledSubscriptions']) && $settings['hideDisabledSubscriptions'] === 'true') {
continue;
}
$id = $subscription['id'];
$print[$id]['id'] = $id;
$print[$id]['logo'] = $subscription['logo'] != "" ? "images/uploads/logos/" . $subscription['logo'] : "";
$print[$id]['name'] = $subscription['name'];
$cycle = $subscription['cycle'];
$frequency = $subscription['frequency'];
$print[$id]['billing_cycle'] = getBillingCycle($cycle, $frequency, $i18n);
$paymentMethodId = $subscription['payment_method_id'];
$print[$id]['currency_code'] = $currencies[$subscription['currency_id']]['code'];
$currencyId = $subscription['currency_id'];
$print[$id]['auto_renew'] = $subscription['auto_renew'];
$next_payment_timestamp = strtotime($subscription['next_payment']);
$formatted_date = $formatter->format($next_payment_timestamp);
$print[$id]['next_payment'] = $formatted_date;
$paymentIconFolder = (strpos($payment_methods[$paymentMethodId]['icon'], 'images/uploads/icons/') !== false) ? "" : "images/uploads/logos/";
$print[$id]['payment_method_icon'] = $paymentIconFolder . $payment_methods[$paymentMethodId]['icon'];
$print[$id]['payment_method_name'] = $payment_methods[$paymentMethodId]['name'];
$print[$id]['payment_method_id'] = $paymentMethodId;
$print[$id]['category_id'] = $subscription['category_id'];
$print[$id]['payer_user_id'] = $subscription['payer_user_id'];
$print[$id]['price'] = floatval($subscription['price']);
$print[$id]['progress'] = getSubscriptionProgress($cycle, $frequency, $subscription['next_payment']);
$print[$id]['inactive'] = $subscription['inactive'];
$print[$id]['url'] = $subscription['url'];
$print[$id]['notes'] = $subscription['notes'];
$print[$id]['replacement_subscription_id'] = $subscription['replacement_subscription_id'];
if (isset($settings['convertCurrency']) && $settings['convertCurrency'] === 'true' && $currencyId != $mainCurrencyId) {
$print[$id]['price'] = getPriceConverted($print[$id]['price'], $currencyId, $db);
$print[$id]['currency_code'] = $currencies[$mainCurrencyId]['code'];
}
if (isset($settings['showMonthlyPrice']) && $settings['showMonthlyPrice'] === 'true') {
$print[$id]['price'] = getPricePerMonth($cycle, $frequency, $print[$id]['price']);
}
if (isset($settings['showOriginalPrice']) && $settings['showOriginalPrice'] === 'true') {
$print[$id]['original_price'] = floatval($subscription['price']);
$print[$id]['original_currency_code'] = $currencies[$subscription['currency_id']]['code'];
}
}
if ($sortOrder == "alphanumeric") {
usort($print, function ($a, $b) {
return strnatcmp(strtolower($a['name']), strtolower($b['name']));
});
if ($settings['disabledToBottom'] === 'true') {
usort($print, function ($a, $b) {
return $a['inactive'] - $b['inactive'];
});
}
}
if (isset($print)) {
printSubscriptions($print, $sort, $categories, $members, $i18n, $colorTheme, "", $settings['disabledToBottom'], $settings['mobileNavigation'], $settings['showSubscriptionProgress'], $currencies, $lang);
}
$db->close();
if (count($subscriptions) == 0) {
?>
<div class="empty-page">
<img src="images/siteimages/empty.png" alt="<?= translate('empty_page', $i18n) ?>" />
<p>
<?= translate('no_subscriptions_yet', $i18n) ?>
</p>
<button class="button" onClick="addSubscription()">
<i class="fa-solid fa-circle-plus"></i>
<?= translate('add_first_subscription', $i18n) ?>
</button>
</div>
<?php
}
?>
</div>
</section>
<section class="subscription-form" id="subscription-form">
<header>
<h3 id="form-title"><?= translate('add_subscription', $i18n) ?></h3>
<span class="fa-solid fa-xmark close-form" onClick="closeAddSubscription()"></span>
</header>
<form action="endpoints/subscription/add.php" method="post" id="subs-form">
<div class="form-group-inline">
<input type="text" id="name" name="name" placeholder="<?= translate('subscription_name', $i18n) ?>"
onchange="setSearchButtonStatus()" onkeypress="this.onchange();" onpaste="this.onchange();"
oninput="this.onchange();" required>
<label for="logo" class="logo-preview">
<img src="" alt="<?= translate('logo_preview', $i18n) ?>" id="form-logo">
</label>
<input type="file" id="logo" name="logo" accept="image/jpeg, image/png, image/gif, image/webp, image/svg+xml"
onchange="handleFileSelect(event)" class="hidden-input">
<input type="hidden" id="logo-url" name="logo-url">
<div id="logo-search-button" class="image-button medium disabled" title="<?= translate('search_logo', $i18n) ?>"
onClick="searchLogo()">
<?php include "images/siteicons/svg/websearch.php"; ?>
</div>
<input type="hidden" id="id" name="id">
<div id="logo-search-results" class="logo-search">
<header>
<?= translate('web_search', $i18n) ?>
<span class="fa-solid fa-xmark close-logo-search" onClick="closeLogoSearch()"></span>
</header>
<div id="logo-search-images"></div>
</div>
</div>
<div class="form-group-inline">
<input type="number" step="0.01" id="price" name="price" placeholder="<?= translate('price', $i18n) ?>" required>
<select id="currency" name="currency_id" placeholder="<?= translate('add_subscription', $i18n) ?>">
<?php
foreach ($currencies as $currency) {
$selected = ($currency['id'] == $main_currency) ? 'selected' : '';
?>
<option value="<?= $currency['id'] ?>" <?= $selected ?>><?= $currency['name'] ?></option>
<?php
}
?>
</select>
</div>
<div class="form-group">
<div class="inline">
<div class="split66">
<label for="cycle"><?= translate('payment_every', $i18n) ?></label>
<div class="inline">
<select id="frequency" name="frequency" placeholder="<?= translate('frequency', $i18n) ?>">
<?php
for ($i = 1; $i <= 366; $i++) {
?>
<option value="<?= $i ?>"><?= $i ?></option>
<?php
}
?>
</select>
<select id="cycle" name="cycle" placeholder="Cycle">
<?php
foreach ($cycles as $cycle) {
?>
<option value="<?= $cycle['id'] ?>" <?= $cycle['id'] == 3 ? "selected" : "" ?>>
<?= translate(strtolower($cycle['name']), $i18n) ?>
</option>
<?php
}
?>
</select>
</div>
</div>
<div class="split33">
<label><?= translate('auto_renewal', $i18n) ?></label>
<div class="inline height50">
<input type="checkbox" id="auto_renew" name="auto_renew" checked>
<label for="auto_renew"><?= translate('automatically_renews', $i18n) ?></label>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="inline">
<div class="split50">
<label for="start_date"><?= translate('start_date', $i18n) ?></label>
<div class="date-wrapper">
<input type="date" id="start_date" name="start_date">
</div>
</div>
<button type="button" id="autofill-next-payment-button"
class="button secondary-button autofill-next-payment hideOnMobile"
title="<?= translate('calculate_next_payment_date', $i18n) ?>" onClick="autoFillNextPaymentDate(event)">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</button>
<div class="split50">
<label for="next_payment" class="split-label">
<?= translate('next_payment', $i18n) ?>
<div id="autofill-next-payment-button" class="autofill-next-payment hideOnDesktop"
title="<?= translate('calculate_next_payment_date', $i18n) ?>" onClick="autoFillNextPaymentDate(event)">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</div>
</label>
<div class="date-wrapper">
<input type="date" id="next_payment" name="next_payment" required>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="inline">
<div class="split50">
<label for="payment_method"><?= translate('payment_method', $i18n) ?></label>
<select id="payment_method" name="payment_method_id">
<?php
foreach ($payment_methods as $payment) {
?>
<option value="<?= $payment['id'] ?>">
<?= $payment['name'] ?>
</option>
<?php
}
?>
</select>
</div>
<div class="split50">
<label for="payer_user"><?= translate('paid_by', $i18n) ?></label>
<select id="payer_user" name="payer_user_id">
<?php
foreach ($members as $member) {
?>
<option value="<?= $member['id'] ?>"><?= $member['name'] ?></option>
<?php
}
?>
</select>
</div>
</div>
</div>
<div class="form-group">
<label for="category"><?= translate('category', $i18n) ?></label>
<select id="category" name="category_id">
<?php
foreach ($categories as $category) {
?>
<option value="<?= $category['id'] ?>">
<?= $category['name'] ?>
</option>
<?php
}
?>
</select>
</div>
<div class="form-group-inline grow">
<input type="checkbox" id="notifications" name="notifications" onchange="toggleNotificationDays()">
<label for="notifications" class="grow"><?= translate('enable_notifications', $i18n) ?></label>
</div>
<div class="form-group">
<div class="inline">
<div class="split66 mobile-split-50">
<label for="notify_days_before"><?= translate('notify_me', $i18n) ?></label>
<select id="notify_days_before" name="notify_days_before" disabled>
<option value="-1"><?= translate('default_value_from_settings', $i18n) ?></option>
<option value="0"><?= translate('on_due_date', $i18n) ?></option>
<option value="1">1 <?= translate('day_before', $i18n) ?></option>
<?php
for ($i = 2; $i <= 90; $i++) {
?>
<option value="<?= $i ?>"><?= $i ?> <?= translate('days_before', $i18n) ?></option>
<?php
}
?>
</select>
</div>
<div class="split33 mobile-split-50">
<label for="cancellation_date"><?= translate('cancellation_notification', $i18n) ?></label>
<div class="date-wrapper">
<input type="date" id="cancellation_date" name="cancellation_date">
</div>
</div>
</div>
</div>
<div class="form-group">
<input type="text" id="url" name="url" placeholder="<?= translate('url', $i18n) ?>">
</div>
<div class="form-group">
<input type="text" id="notes" name="notes" placeholder="<?= translate('notes', $i18n) ?>">
</div>
<div class="form-group">
<div class="inline grow">
<input type="checkbox" id="inactive" name="inactive" onchange="toggleReplacementSub()">
<label for="inactive" class="grow"><?= translate('inactive', $i18n) ?></label>
</div>
</div>
<?php
$orderedSubscriptions = $subscriptions;
usort($orderedSubscriptions, function ($a, $b) {
return strnatcmp(strtolower($a['name']), strtolower($b['name']));
});
?>
<div class="form-group hide" id="replacement_subscritpion">
<label for="replacement_subscription_id"><?= translate('replaced_with', $i18n) ?>:</label>
<select id="replacement_subscription_id" name="replacement_subscription_id">
<option value="0"><?= translate('none', $i18n) ?></option>
<?php
foreach ($orderedSubscriptions as $sub) {
if ($sub['inactive'] == 0) {
?>
<option value="<?= htmlspecialchars($sub['id']) ?>"><?= htmlspecialchars($sub['name']) ?>
</option>
<?php
}
}
?>
</select>
</div>
<div class="buttons">
<input type="button" value="<?= translate('delete', $i18n) ?>" class="warning-button left thin" id="deletesub"
style="display: none">
<input type="button" value="<?= translate('cancel', $i18n) ?>" class="secondary-button thin"
onClick="closeAddSubscription()">
<input type="submit" value="<?= translate('save', $i18n) ?>" class="thin" id="save-button">
</div>
</form>
</section>
<script src="scripts/subscriptions.js?<?= $version ?>"></script>
<?php
if (isset($_GET['add'])) {
?>
<script>
addSubscription();
</script>
<?php
}
require_once 'includes/footer.php';
?>