Quote what a person pays, and tab the settings page
Prices. The sheet quoted net, which is the wrong number for one of its two readers: a private customer has no way to add 20 % in their head and no business being surprised by it at checkout. The gross figure is now the big one, with the net underneath for a company's books, and the same rule applies to the module prices further down — a sheet that quotes the package with VAT and the module without it is a sheet whose numbers cannot be added up. The rate is the one on the Finance page, the same one an invoice uses; a percentage written into the template would be the second source that makes the two disagree. "zzgl. einmaliger Einrichtung" had run for months without ever naming a figure, which leaves a visitor knowing only that there is one. It is a field on the Finance page now and prints with the price. Zero removes the sentence — and the comparison card that listed the fee as the honest downside of the offer — rather than admitting to a cost that is not charged. Layout. Four packages in 1120px left each card 202px of text, which is what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the cards look pressed against each other. The pricing section is wider than the rest of the page (only it: the reading columns elsewhere are narrow on purpose, a price sheet is four columns that must be comparable at a glance), the gap between cards is larger, and the delivery line is short enough to fit with whitespace-nowrap to keep it that way. Navigation. VPN moves from Betrieb to System: the tunnel is not a task of the day, it is how the console reaches the estate at all. Settings. The page had grown to three headed sections in one column with the operator's own password somewhere in the middle. It is tabbed now, and two-factor enrolment is one of the tabs instead of a menu entry of its own — it is a setting, and it was the one thing an operator had to leave this page to change about their own access. Same component, no second copy of the enrolment logic; it still answers at its own route because RequireOperatorTwoFactor sends an operator there who may not open anything else yet, this page included. The open tab lives in the query string, so a reload, a bookmark and the back button all land where the operator was. That matters here more than elsewhere: the installation tab refreshes itself while an update runs, and a tab kept only in the component would drop them back to the first one mid-deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus v1.3.28
parent
5b63fdb86c
commit
7265799881
|
|
@ -7,6 +7,7 @@ use App\Services\Billing\AddonCatalogue;
|
|||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\CompanyProfile;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -195,8 +196,24 @@ class LandingController extends Controller
|
|||
// print two different figures for the same thing.
|
||||
$modules = $this->modulePrices();
|
||||
|
||||
$tax = CompanyProfile::taxRate();
|
||||
$setupNet = CompanyProfile::setupFeeCents();
|
||||
|
||||
return view('landing', [
|
||||
'plans' => $plans,
|
||||
// What the page has to say about tax and about the one-off fee, in
|
||||
// one place. Both are console-editable figures, and both were
|
||||
// previously sentences on the page: "Netto pro Monat" and "zzgl.
|
||||
// einmaliger Einrichtung" — the second of which never named a
|
||||
// number at all, so a visitor learned only that there was one.
|
||||
'vat' => [
|
||||
'rate' => $tax,
|
||||
'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
|
||||
],
|
||||
'setup' => $setupNet === 0 ? null : [
|
||||
'gross' => $this->money($this->gross($setupNet), Subscription::catalogueCurrency()),
|
||||
'net' => $this->money($setupNet, Subscription::catalogueCurrency()),
|
||||
],
|
||||
'baseline' => $this->baseline($plans),
|
||||
'comparison' => $this->comparison($plans, $modules),
|
||||
'addons' => $this->addons($modules),
|
||||
|
|
@ -481,7 +498,12 @@ class LandingController extends Controller
|
|||
// template renders that sensibly instead of an empty gap.
|
||||
'audience' => (string) ($plan['audience'] ?? ''),
|
||||
'note' => (string) ($plan['note'] ?? ''),
|
||||
'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
|
||||
// GROSS, because a private customer must never be quoted a
|
||||
// figure that grows at checkout. A business reads the net one
|
||||
// underneath and gets the VAT back anyway; a consumer does not,
|
||||
// and for them the net figure was simply the wrong number.
|
||||
'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
|
||||
'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
|
||||
'storage' => $this->storage((int) $plan['quota_gb']),
|
||||
'traffic' => $this->storage((int) $plan['traffic_gb']),
|
||||
'seats' => (int) $plan['seats'],
|
||||
|
|
@ -533,6 +555,23 @@ class LandingController extends Controller
|
|||
* Whole units when the amount is whole — a price sheet reads "179", not
|
||||
* "179,00".
|
||||
*/
|
||||
/**
|
||||
* A net figure with the domestic VAT put back on.
|
||||
*
|
||||
* The same rate an invoice would use, read from the same place
|
||||
* (App\Services\Billing\TaxTreatment does too) — a percentage written
|
||||
* into the marketing page would be the second source that makes the sheet
|
||||
* and the invoice disagree.
|
||||
*
|
||||
* Cross-border reverse charge is deliberately not modelled here: it depends
|
||||
* on a VERIFIED VAT id, which a visitor to a public page does not have. The
|
||||
* page quotes the domestic price and the invoice applies the treatment.
|
||||
*/
|
||||
private function gross(int $netCents): int
|
||||
{
|
||||
return (int) round($netCents * (1 + CompanyProfile::taxRate() / 100));
|
||||
}
|
||||
|
||||
private function money(int $cents, string $currency): string
|
||||
{
|
||||
$amount = $cents % 100 === 0
|
||||
|
|
@ -554,7 +593,10 @@ class LandingController extends Controller
|
|||
*/
|
||||
private function modulePrice(int $cents): string
|
||||
{
|
||||
return $this->money($cents, Subscription::catalogueCurrency());
|
||||
// Gross, like every other figure on this page. A sheet that quotes the
|
||||
// package with VAT and the module without it is a sheet where the two
|
||||
// numbers cannot be added up.
|
||||
return $this->money($this->gross($cents), Subscription::catalogueCurrency());
|
||||
}
|
||||
|
||||
private function storage(int $gb): string
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ class Finance extends Component
|
|||
|
||||
public float $taxRate = 20.0;
|
||||
|
||||
/**
|
||||
* The one-off setup fee, in euro, as a person types it.
|
||||
*
|
||||
* Held in euro rather than cents because this is a form field, and asking
|
||||
* an operator to enter 9900 for ninety-nine euro is how a fee ends up a
|
||||
* hundred times too large. Converted on the way in and out.
|
||||
*/
|
||||
public string $setupFee = '0';
|
||||
|
||||
/**
|
||||
* A freshly minted collection key, shown once and never stored.
|
||||
*
|
||||
|
|
@ -63,6 +72,7 @@ class Finance extends Component
|
|||
$this->company = CompanyProfile::all();
|
||||
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
|
||||
$this->taxRate = CompanyProfile::taxRate();
|
||||
$this->setupFee = number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', '');
|
||||
}
|
||||
|
||||
public function saveCompany(): void
|
||||
|
|
@ -91,6 +101,7 @@ class Finance extends Component
|
|||
// ever rendered from then on.
|
||||
'logo' => 'nullable|image|mimes:png,webp|max:1024',
|
||||
'taxRate' => 'required|numeric|min:0|max:100',
|
||||
'setupFee' => 'required|numeric|min:0|max:100000',
|
||||
]);
|
||||
|
||||
if ($this->logo !== null) {
|
||||
|
|
@ -111,6 +122,10 @@ class Finance extends Component
|
|||
|
||||
CompanyProfile::put($data['company']);
|
||||
Settings::set('company.tax_rate', (float) $data['taxRate']);
|
||||
// round(), not a cast: (int) (99.95 * 100) is 9994 on a binary float,
|
||||
// and a fee one cent short of what was typed is the kind of thing
|
||||
// nobody finds until a customer does.
|
||||
Settings::set('company.setup_fee_cents', (int) round(((float) $data['setupFee']) * 100));
|
||||
|
||||
$this->dispatch('notify', message: __('finance.company_saved'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use Illuminate\Support\Facades\DB;
|
|||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
|
@ -27,10 +28,19 @@ use Symfony\Component\HttpFoundation\IpUtils;
|
|||
* staff management. All staff mutations are capability-gated server-side and
|
||||
* protect the last-Owner and self-role invariants transactionally.
|
||||
*
|
||||
* Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup — see
|
||||
* RequireOperatorTwoFactor for why. Only the POLICY switch
|
||||
* (saveTwoFactorPolicy(), below) stays here: only someone who already
|
||||
* satisfies the policy should be the one changing it.
|
||||
* Two-factor ENROLMENT is the `two-factor` tab, rendered by the unchanged
|
||||
* Admin\TwoFactorSetup component. That component also still answers at its own
|
||||
* route, because RequireOperatorTwoFactor sends an operator there who may not
|
||||
* open anything else yet — this page included. One component, two ways in: the
|
||||
* everyday one is a tab beside the other settings, the other is a gate.
|
||||
*
|
||||
* The page is TABBED rather than one column of eleven cards. It had grown to
|
||||
* three headed sections and roughly two thousand pixels of scroll, and the tab
|
||||
* a person wants is a property of what they came to do, not of how far they
|
||||
* scroll. The choice lives in the query string (?tab=…), so a reload, a
|
||||
* bookmark and the back button all land where the operator was — a tab state
|
||||
* kept only in the component is a tab state that resets on every refresh, and
|
||||
* this page refreshes itself while an update runs.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Settings extends Component
|
||||
|
|
@ -38,6 +48,25 @@ class Settings extends Component
|
|||
use ChangesOwnPassword;
|
||||
use ResolvesOperator;
|
||||
|
||||
/**
|
||||
* The tabs, in the order they are shown.
|
||||
*
|
||||
* The list IS the schema: it validates the query string, builds the tab
|
||||
* bar, and decides which section renders. A fourth place to add a tab is a
|
||||
* fourth place to forget one.
|
||||
*/
|
||||
public const TABS = ['installation', 'account', 'two-factor', 'team'];
|
||||
|
||||
/**
|
||||
* Which one is open, taken from and written back to the URL.
|
||||
*
|
||||
* `except` keeps ?tab=installation out of the address bar: the default
|
||||
* needs no parameter, and a query string that appears the moment a page
|
||||
* loads makes every link to it look different from the one in the menu.
|
||||
*/
|
||||
#[Url(except: 'installation')]
|
||||
public string $tab = 'installation';
|
||||
|
||||
/** This page changes the signed-in OPERATOR's password, never a portal one. */
|
||||
protected function passwordAccountGuard(): string
|
||||
{
|
||||
|
|
@ -68,10 +97,25 @@ class Settings extends Component
|
|||
|
||||
public function mount(): void
|
||||
{
|
||||
// A tab name out of the URL is a string a stranger typed. Anything
|
||||
// unknown falls back to the first tab rather than rendering a settings
|
||||
// page with no section on it at all. Before the early return, because
|
||||
// the tab has to be valid whether or not an operator resolves.
|
||||
if (! in_array($this->tab, self::TABS, true)) {
|
||||
$this->tab = self::TABS[0];
|
||||
}
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ?tab=team without the capability would open a tab that renders
|
||||
// nothing — the section is gated on its own, as it must be. Landing on
|
||||
// the first tab says the same thing without the blank page.
|
||||
if ($this->tab === 'team' && ! $operator->can('staff.manage')) {
|
||||
$this->tab = self::TABS[0];
|
||||
}
|
||||
|
||||
$this->name = $operator->name;
|
||||
$this->email = $operator->email;
|
||||
$this->requireTwoFactor = AppSettings::bool('console.require_2fa', false);
|
||||
|
|
@ -545,6 +589,12 @@ class Settings extends Component
|
|||
'roles' => Operator::OPERATOR_ROLES,
|
||||
'canManageStaff' => $operator?->can('staff.manage') ?? false,
|
||||
'isOwner' => $operator?->hasRole('Owner') ?? false,
|
||||
// Only the tabs this operator can actually open. A tab that raises
|
||||
// a 403 is worse than one that is not there.
|
||||
'tabs' => array_values(array_filter(
|
||||
self::TABS,
|
||||
fn (string $tab) => $tab !== 'team' || ($operator?->can('staff.manage') ?? false),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ use Livewire\Component;
|
|||
#[Layout('layouts.admin')]
|
||||
class TwoFactorSetup extends Component
|
||||
{
|
||||
/**
|
||||
* Rendered inside the settings page rather than as a page of its own.
|
||||
*
|
||||
* Only the heading differs: as a tab it sits under the settings title, and
|
||||
* a second <h1> saying "Zwei-Faktor" directly beneath "Einstellungen" is
|
||||
* two page titles on one page. Nothing about what the component DOES
|
||||
* changes — the password gate, the enrolment and every server-side check
|
||||
* are the same code on both routes in.
|
||||
*/
|
||||
public bool $embedded = false;
|
||||
|
||||
use ConfirmsPassword;
|
||||
use ResolvesOperator;
|
||||
|
||||
|
|
|
|||
|
|
@ -111,4 +111,22 @@ final class CompanyProfile
|
|||
{
|
||||
return (float) Settings::get('company.tax_rate', (float) config('provisioning.tax.rate_percent', 20));
|
||||
}
|
||||
|
||||
/**
|
||||
* What setting a customer up costs once, net, in cents.
|
||||
*
|
||||
* The price sheet has always said "zzgl. einmaliger Einrichtung" without
|
||||
* ever naming a figure — a price that is announced and not stated is worse
|
||||
* than no price at all, because the visitor now knows there is one and has
|
||||
* to ask. Editable here for the same reason as the tax rate: it is a
|
||||
* commercial number that lands on an invoice, and an operator must be able
|
||||
* to change it without a deployment.
|
||||
*
|
||||
* Zero means there is no such fee, and the sentence disappears from the
|
||||
* page entirely rather than announcing nothing.
|
||||
*/
|
||||
public static function setupFeeCents(): int
|
||||
{
|
||||
return (int) Settings::get('company.setup_fee_cents', 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ final class Navigation
|
|||
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
||||
['admin.incidents', 'bell', 'incidents', null],
|
||||
['admin.capacity', 'database', 'capacity', 'hosts.manage'],
|
||||
['admin.vpn', 'shield', 'vpn', null],
|
||||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
// Its own entry, not a section of Settings: what is set here
|
||||
// appears on a legal document, and burying it beside the
|
||||
|
|
@ -64,6 +63,11 @@ final class Navigation
|
|||
]],
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
// System, not Betrieb: the tunnel is how the console reaches
|
||||
// the estate at all. Nothing is provisioned, monitored or
|
||||
// updated through anything else, so it belongs beside the other
|
||||
// things that have to work before any of the day's work can.
|
||||
['admin.vpn', 'shield', 'vpn', null],
|
||||
// Merged from the former admin.secrets + admin.infrastructure
|
||||
// pages, which split the same subject by storage mechanism
|
||||
// instead of by what it configures. Reachable with EITHER
|
||||
|
|
@ -73,12 +77,14 @@ final class Navigation
|
|||
// never "all of these" (contrast a single string elsewhere in
|
||||
// this file, which x-shell.nav checks with plain ->can()).
|
||||
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
|
||||
// Two-factor enrolment used to sit here as an entry of its
|
||||
// own. It is a tab of Settings now — it is a setting, and it
|
||||
// was the one place an operator had to leave the settings page
|
||||
// to change something about their own access. The standalone
|
||||
// page stays reachable at its route, because that is where
|
||||
// RequireOperatorTwoFactor sends somebody who may not open
|
||||
// anything else yet, Settings included.
|
||||
['admin.settings', 'settings', 'settings', null],
|
||||
// null capability, deliberately: the compulsory two-factor
|
||||
// switch (Admin\Settings::saveTwoFactorPolicy()) applies to
|
||||
// every operator, so enrolment has to be reachable by every
|
||||
// operator too, not only the ones who can manage the site.
|
||||
['admin.two-factor-setup', 'shield-check', 'two_factor_setup', null],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,4 +198,14 @@ return [
|
|||
'account' => 'Mein Konto',
|
||||
'team' => 'Team & Zugriff',
|
||||
],
|
||||
|
||||
// The tab bar. Kept apart from `group` — those were the headings of a
|
||||
// scrolled page and read as such ("Installation" over a divider line); a
|
||||
// tab is a thing you click, and it has room for fewer words.
|
||||
'tab' => [
|
||||
'installation' => 'Installation',
|
||||
'account' => 'Mein Konto',
|
||||
'two_factor' => 'Zwei-Faktor',
|
||||
'team' => 'Team',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
// branch is a promise about buying and preparing a machine — that one is ours
|
||||
// to keep.
|
||||
return [
|
||||
'immediate' => 'Wird sofort ausgeliefert',
|
||||
'scheduled' => 'Bereitstellung in 2–3 Werktagen',
|
||||
'immediate' => 'Sofort verfügbar',
|
||||
'scheduled' => 'In 2–3 Werktagen da',
|
||||
|
||||
// The longer form, where there is room for a sentence: the customer's own
|
||||
// overview while their instance is waiting for a machine.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ return [
|
|||
'archive_path' => 'Archivpfad',
|
||||
'logo' => 'Logo',
|
||||
'tax_rate' => 'Umsatzsteuer (%)',
|
||||
'setup_fee' => 'Einrichtung, einmalig (netto)',
|
||||
'payment_days' => 'Zahlungsziel (Tage)',
|
||||
'payment_terms' => 'Zahlungsbedingungen',
|
||||
],
|
||||
|
|
@ -46,6 +47,7 @@ return [
|
|||
'archive_path' => 'Verzeichnis, in das eine Kopie jeder Rechnung geschrieben wird — z. B. der Einhängepunkt Ihrer NAS. Leer lassen, um nichts zu kopieren.',
|
||||
'vat_id' => 'Pflichtangabe auf jeder Rechnung.',
|
||||
'tax_rate' => 'Wird in jede Rechnung eingefroren. Eine Änderung wirkt nur auf neue Rechnungen.',
|
||||
'setup_fee' => 'Steht so auf der Preisliste der Website. 0 bedeutet: keine Einrichtungsgebühr, und der Satz entfällt dort ganz.',
|
||||
'logo' => 'PNG oder WEBP, höchstens 1 MB. Wird in jede erzeugte Rechnung eingebettet.',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -197,4 +197,14 @@ return [
|
|||
'account' => 'My account',
|
||||
'team' => 'Team & access',
|
||||
],
|
||||
|
||||
// The tab bar. Kept apart from `group` — those were the headings of a
|
||||
// scrolled page and read as such; a tab is a thing you click, and it has
|
||||
// room for fewer words.
|
||||
'tab' => [
|
||||
'installation' => 'Installation',
|
||||
'account' => 'My account',
|
||||
'two_factor' => 'Two-factor',
|
||||
'team' => 'Team',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
// branch is a promise about buying and preparing a machine — that one is ours
|
||||
// to keep.
|
||||
return [
|
||||
'immediate' => 'Delivered right away',
|
||||
'scheduled' => 'Ready within 2–3 working days',
|
||||
'immediate' => 'Available right away',
|
||||
'scheduled' => 'Ready in 2–3 working days',
|
||||
|
||||
// The longer form, where there is room for a sentence: the customer's own
|
||||
// overview while their instance is waiting for a machine.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ return [
|
|||
'archive_path' => 'Archive path',
|
||||
'logo' => 'Logo',
|
||||
'tax_rate' => 'VAT (%)',
|
||||
'setup_fee' => 'Setup, one-off (net)',
|
||||
'payment_days' => 'Payment terms (days)',
|
||||
'payment_terms' => 'Payment conditions',
|
||||
],
|
||||
|
|
@ -46,6 +47,7 @@ return [
|
|||
'archive_path' => 'Directory a copy of every invoice is written to — your NAS mount point, for instance. Leave empty to copy nothing.',
|
||||
'vat_id' => 'Required on every invoice.',
|
||||
'tax_rate' => 'Frozen into every invoice. A change affects new invoices only.',
|
||||
'setup_fee' => 'Shown as-is on the public price sheet. 0 means there is no setup fee, and the sentence disappears from it.',
|
||||
'logo' => 'PNG or WEBP, 1 MB at most. Embedded into every invoice rendered.',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@
|
|||
$immediate = $state === 'immediate';
|
||||
@endphp
|
||||
|
||||
<p {{ $attributes->class(['flex items-center gap-2 text-xs font-medium']) }}>
|
||||
{{-- One line, guaranteed. In a four-column price sheet the wording had a
|
||||
column 202px wide to live in and broke over two lines, which put the icon
|
||||
beside the first half of a sentence and the rest under it — the shape R18
|
||||
exists to prevent. The wording is short enough to fit now, and
|
||||
whitespace-nowrap makes that a property of the component rather than a
|
||||
coincidence of the current translation. --}}
|
||||
<p {{ $attributes->class(['flex items-center gap-2 whitespace-nowrap text-xs font-medium']) }}>
|
||||
<x-ui.icon :name="$immediate ? 'check' : 'calendar'"
|
||||
@class(['size-4 shrink-0', 'text-success' => $immediate, 'text-muted' => ! $immediate]) />
|
||||
<span @class(['text-success' => $immediate, 'text-muted' => ! $immediate])>
|
||||
|
|
|
|||
|
|
@ -422,7 +422,10 @@
|
|||
[true, 'Eigene isolierte Instanz, Standort EU, AV-Vertrag inklusive'],
|
||||
[true, 'Fixer Firmenpreis, unabhängig von der Kopfzahl'],
|
||||
[true, 'Betrieb, Sicherung und Überwachung nachweislich durch uns'],
|
||||
[false, 'Einrichtung kostet einmalig — wir richten wirklich ein, statt nur freizuschalten'],
|
||||
{{-- With the figure, and only while there is one. An
|
||||
installation that charges no setup fee must not carry a
|
||||
card admitting to a cost it does not have. --}}
|
||||
...($setup ? [[false, 'Einrichtung kostet einmalig '.$setup['gross'].' — wir richten wirklich ein, statt nur freizuschalten']] : []),
|
||||
]],
|
||||
] as $i => [$option, $subtitle, $ours, $points])
|
||||
<div @class([
|
||||
|
|
@ -456,14 +459,28 @@
|
|||
Cards, with the matrix folded away underneath. The matrix was the first
|
||||
thing a visitor met on the old page: twelve rows of ticks before a single
|
||||
price was legible. --}}
|
||||
<section id="pricing" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
{{-- Wider than the rest of the page, and only here. Four packages side by
|
||||
side in 1120px left each card 202px of text — narrow enough that
|
||||
"Bereitstellung in 2-3 Werktagen" broke over two lines and every card
|
||||
looked crammed against its neighbours. The reading columns elsewhere are
|
||||
narrow on purpose; a price sheet is four columns that must be comparable
|
||||
at a glance, which is a different problem. --}}
|
||||
<section id="pricing" class="mx-auto max-w-[1240px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
<div class="rv mx-auto max-w-[46ch] text-center">
|
||||
<p class="lbl !text-accent-text">Preise</p>
|
||||
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
|
||||
Ein fixer Preis pro Firma.
|
||||
</h2>
|
||||
<p class="mt-5 text-md leading-relaxed text-muted">
|
||||
Netto pro Monat. Keine Staffelung nach Benutzern, keine Nachverrechnung bei Zuwachs.
|
||||
Alle Preise inklusive {{ $vat['label'] }} % Umsatzsteuer. Keine Staffelung nach Benutzern,
|
||||
keine Nachverrechnung bei Zuwachs.
|
||||
</p>
|
||||
{{-- Said once, here, rather than as a footnote per card: a consumer must
|
||||
never be quoted a figure that grows at checkout, and a business
|
||||
needs to know the net one is on the invoice. --}}
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
Als Unternehmen ziehen Sie die Umsatzsteuer über die Rechnung wieder ab — den Nettobetrag
|
||||
finden Sie unter jedem Preis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -487,7 +504,7 @@
|
|||
{{-- Stretched, not items-start: the cards carry different numbers of
|
||||
features, and four "anfragen" buttons at four different heights is
|
||||
the first thing the eye picks up on a price sheet. --}}
|
||||
<div class="mt-14 grid gap-4 sm:grid-cols-2 {{ $planColumns }}">
|
||||
<div class="mt-14 grid gap-5 sm:grid-cols-2 lg:gap-6 {{ $planColumns }}">
|
||||
@foreach ($plans as $i => $plan)
|
||||
<div @class([
|
||||
'rv flex flex-col rounded-xl border p-7',
|
||||
|
|
@ -512,7 +529,23 @@
|
|||
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>
|
||||
<span class="text-sm text-muted">/Monat</span>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">zzgl. einmaliger Einrichtung</p>
|
||||
{{-- The net figure under the gross one, not instead of it.
|
||||
A private customer pays what is written large; a
|
||||
business needs the other number for its books, and
|
||||
printing only one of the two was wrong for one of the
|
||||
two readers whichever we picked. --}}
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }}
|
||||
</p>
|
||||
{{-- Named, or not mentioned. "zzgl. einmaliger Einrichtung"
|
||||
without a figure told a visitor only that there is a
|
||||
cost — the worst of both. Zero on the Finance page
|
||||
removes the line entirely. --}}
|
||||
@if ($setup)
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
zzgl. Einrichtung einmalig {{ $setup['gross'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Read from the estate, not written down: where a host has
|
||||
room this rolls out on its own, and where none has, a
|
||||
|
|
@ -644,7 +677,7 @@
|
|||
</table>
|
||||
</div>
|
||||
<p class="border-t border-line px-6 py-4 text-xs leading-relaxed text-muted">
|
||||
„optional“ heißt: in diesem Paket nicht enthalten, aber jederzeit zubuchbar — netto pro Monat, monatlich kündbar.
|
||||
„optional“ heißt: in diesem Paket nicht enthalten, aber jederzeit zubuchbar — brutto pro Monat, monatlich kündbar.
|
||||
„—“ heißt: in diesem Paket nicht verfügbar.
|
||||
</p>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@
|
|||
<p class="mt-1 text-xs text-muted">{{ __('finance.hint.tax_rate') }}</p>
|
||||
@error('taxRate')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
{{-- The figure behind "zzgl. einmaliger Einrichtung". The price
|
||||
sheet announced the fee for months without ever naming it,
|
||||
which leaves a visitor knowing only that there is one. Zero
|
||||
removes the sentence from the page rather than printing a
|
||||
promise of nothing. --}}
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="setup-fee">{{ __('finance.field.setup_fee') }}</label>
|
||||
<input id="setup-fee" type="number" step="0.01" min="0" wire:model="setupFee"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink" />
|
||||
<p class="mt-1 text-xs text-muted">{{ __('finance.hint.setup_fee') }}</p>
|
||||
@error('setupFee')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 border-t border-line pt-5 sm:grid-cols-3">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,35 @@
|
|||
<div class="mx-auto max-w-[1120px] space-y-10">
|
||||
<div class="mx-auto max-w-[1120px] space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- ── The tabs ──────────────────────────────────────────────────────
|
||||
The page was three headed sections in one column, two thousand pixels
|
||||
of scroll, with the operator's own password somewhere in the middle of
|
||||
it. Which part somebody wants is a property of what they came to do,
|
||||
not of how far they scroll.
|
||||
|
||||
The choice is in the query string (see the #[Url] attribute), so a
|
||||
reload, a bookmark and the back button all land where the operator
|
||||
was. That matters here more than on most pages: the installation tab
|
||||
refreshes itself while an update runs, and a tab state kept only in the
|
||||
component would drop them back to the first tab mid-deployment. --}}
|
||||
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
|
||||
@foreach ($tabs as $name)
|
||||
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
|
||||
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
|
||||
@class([
|
||||
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
|
||||
'border-accent-active text-ink' => $tab === $name,
|
||||
'border-transparent text-muted hover:text-ink' => $tab !== $name,
|
||||
])>
|
||||
<x-ui.icon :name="['installation' => 'server', 'account' => 'users', 'two-factor' => 'shield-check', 'team' => 'users'][$name] ?? 'settings'"
|
||||
class="size-4" />{{ __('admin_settings.tab.'.str_replace('-', '_', $name)) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Die Installation ─────────────────────────────────────────────────
|
||||
Grouped and given headings because the page was one undifferentiated
|
||||
stack: eight cards of the same weight in a column half the window wide,
|
||||
|
|
@ -11,449 +37,453 @@
|
|||
switches. What each card belongs to is now said out loud, and the short
|
||||
ones sit side by side instead of each taking a full row to hold three
|
||||
lines of text. --}}
|
||||
<section class="space-y-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="lbl">{{ __('admin_settings.group.installation') }}</h2>
|
||||
<span class="h-px flex-1 bg-line"></span>
|
||||
</div>
|
||||
@if ($tab === 'installation')
|
||||
<section class="space-y-5">
|
||||
|
||||
{{-- Version & update --}}
|
||||
@if ($canManageSite)
|
||||
{{-- Polls itself: an operator watching an update run should not have to
|
||||
reload the page to find out whether it finished. Fast while
|
||||
something is happening, slow when nothing is.
|
||||
{{-- Version & update --}}
|
||||
@if ($canManageSite)
|
||||
{{-- Polls itself: an operator watching an update run should not have to
|
||||
reload the page to find out whether it finished. Fast while
|
||||
something is happening, slow when nothing is.
|
||||
|
||||
wire:poll alone is not enough, because the thing being watched
|
||||
restarts the thing doing the watching. Mid-run the requests fail,
|
||||
and what answers afterwards is a NEW build being questioned by the
|
||||
old page's JavaScript — so the card sat on "läuft" until somebody
|
||||
reloaded. That gap is now covered by the full-screen overlay in
|
||||
layouts/admin.blade.php, which binds the same watcher once for
|
||||
every console page rather than here — this card no longer binds
|
||||
it itself, so the two do not double-poll while this page is
|
||||
open. wire:poll below is unrelated and stays: it is Livewire's
|
||||
own refresh of this card's own details (log tail, elapsed time)
|
||||
for as long as a Livewire connection exists. --}}
|
||||
{{-- The poll stops while a run is in flight. The overlay's own
|
||||
watcher is what follows a deployment — a plain fetch with no
|
||||
component state behind it, which is why it survives the
|
||||
restart. This one cannot, and every attempt during maintenance
|
||||
mode was a Livewire request answering 503. --}}
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
|
||||
@if (! $update['running']) wire:poll.{{ $update['checking'] ? '3s' : '30s' }} @endif>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.update_title') }}</h2>
|
||||
wire:poll alone is not enough, because the thing being watched
|
||||
restarts the thing doing the watching. Mid-run the requests fail,
|
||||
and what answers afterwards is a NEW build being questioned by the
|
||||
old page's JavaScript — so the card sat on "läuft" until somebody
|
||||
reloaded. That gap is now covered by the full-screen overlay in
|
||||
layouts/admin.blade.php, which binds the same watcher once for
|
||||
every console page rather than here — this card no longer binds
|
||||
it itself, so the two do not double-poll while this page is
|
||||
open. wire:poll below is unrelated and stays: it is Livewire's
|
||||
own refresh of this card's own details (log tail, elapsed time)
|
||||
for as long as a Livewire connection exists. --}}
|
||||
{{-- The poll stops while a run is in flight. The overlay's own
|
||||
watcher is what follows a deployment — a plain fetch with no
|
||||
component state behind it, which is why it survives the
|
||||
restart. This one cannot, and every attempt during maintenance
|
||||
mode was a Livewire request answering 503. --}}
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
|
||||
@if (! $update['running']) wire:poll.{{ $update['checking'] ? '3s' : '30s' }} @endif>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.update_title') }}</h2>
|
||||
@if ($update['running'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
|
||||
</span>
|
||||
@elseif ($update['checking'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_checking') }}
|
||||
</span>
|
||||
@elseif ($update['available'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{-- The version, when it is known: "Version 1.1.0
|
||||
verfügbar" is a thing an operator can decide
|
||||
about; "1 Aktualisierung verfügbar" is not. --}}
|
||||
@if ($update['target_release'])
|
||||
{{ __('admin_settings.update_available_release', ['version' => ltrim($update['target_release'], 'v')]) }}
|
||||
@else
|
||||
{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
|
||||
@endif
|
||||
</span>
|
||||
@elseif ($update['behind'] === 0)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_current') }}
|
||||
</span>
|
||||
@else
|
||||
{{-- Never shown as "up to date": not knowing and being
|
||||
current are different answers. --}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-line-strong bg-surface-2 px-2.5 py-0.5 text-xs font-medium text-muted">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_unknown') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-1 text-sm">
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_version') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif</dd></div>
|
||||
@if ($update['source'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_source') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $update['source'] }}</dd></div>
|
||||
@endif
|
||||
@if ($update['deployed_at'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_deployed') }}:</dt>
|
||||
<dd class="text-body">{{ $update['deployed_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
@if ($update['checked_at'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_checked') }}:</dt>
|
||||
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{-- Bound, not @disabled(): the directive compiles to inline PHP
|
||||
inside the component tag, and Blade then stops seeing a
|
||||
component.
|
||||
|
||||
Two separate actions, always offered together: looking and
|
||||
applying are different intentions, and an operator who only
|
||||
wants to know whether anything is new must not be routed
|
||||
through a real deployment to find out.
|
||||
|
||||
"Jetzt aktualisieren" now requires a released version to
|
||||
install. It used to be offered unconditionally, because
|
||||
`behind` was a reading taken up to a minute ago and a
|
||||
console that had not noticed the last commit yet would
|
||||
refuse to act on it. That reasoning belonged to counting
|
||||
commits on a branch, where the answer changes several times
|
||||
an hour. An update is a TAG now: it appears when somebody
|
||||
releases one, not when somebody pushes — and pressing the
|
||||
check button answers within a second, so a stale reading is
|
||||
no longer something to work around.
|
||||
|
||||
Disabled for the cases where it would go nowhere: nothing
|
||||
released above the installed version, a run already in
|
||||
flight, no agent, or a check already pending (so a second
|
||||
click cannot collide with the first and be refused). --}}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if ($update['running'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
|
||||
</span>
|
||||
@elseif ($update['checking'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_checking') }}
|
||||
</span>
|
||||
@elseif ($update['available'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{-- The version, when it is known: "Version 1.1.0
|
||||
verfügbar" is a thing an operator can decide
|
||||
about; "1 Aktualisierung verfügbar" is not. --}}
|
||||
@if ($update['target_release'])
|
||||
{{ __('admin_settings.update_available_release', ['version' => ltrim($update['target_release'], 'v')]) }}
|
||||
@else
|
||||
{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
|
||||
@endif
|
||||
</span>
|
||||
@elseif ($update['behind'] === 0)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_current') }}
|
||||
</span>
|
||||
<x-ui.button variant="secondary" :disabled="true">
|
||||
<x-ui.icon name="refresh" class="size-4 animate-spin" />
|
||||
{{ __('admin_settings.update_running') }}
|
||||
</x-ui.button>
|
||||
@elseif (! $update['agent_seen'])
|
||||
<x-ui.button variant="secondary" :disabled="true">{{ __('admin_settings.update_unknown') }}</x-ui.button>
|
||||
@else
|
||||
{{-- Never shown as "up to date": not knowing and being
|
||||
current are different answers. --}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-line-strong bg-surface-2 px-2.5 py-0.5 text-xs font-medium text-muted">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_unknown') }}
|
||||
</span>
|
||||
<x-ui.button variant="secondary" :disabled="$update['checking']"
|
||||
wire:click="requestCheck" wire:loading.attr="disabled" wire:target="requestCheck">
|
||||
<x-ui.icon name="refresh" class="size-4 {{ $update['checking'] ? 'animate-spin' : '' }}" />
|
||||
{{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="primary" :disabled="$update['checking'] || ! $update['available']"
|
||||
wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
{{ __('admin_settings.update_now') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-1 text-sm">
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_version') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif</dd></div>
|
||||
@if ($update['source'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_source') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $update['source'] }}</dd></div>
|
||||
@endif
|
||||
@if ($update['deployed_at'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_deployed') }}:</dt>
|
||||
<dd class="text-body">{{ $update['deployed_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
@if ($update['checked_at'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_checked') }}:</dt>
|
||||
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{-- Bound, not @disabled(): the directive compiles to inline PHP
|
||||
inside the component tag, and Blade then stops seeing a
|
||||
component.
|
||||
@if (! $update['agent_seen'])
|
||||
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
|
||||
@elseif ($update['last_error'])
|
||||
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
|
||||
@elseif (! $update['available'] && ! $update['running'] && ! $update['checking'])
|
||||
{{-- Why the button is grey. Without this the console looks
|
||||
broken to whoever just pushed to main and expects to see
|
||||
something: it is not broken, it is waiting for a release. --}}
|
||||
<p class="mt-3 text-xs text-muted">{{ __('admin_settings.update_only_releases') }}</p>
|
||||
@endif
|
||||
|
||||
Two separate actions, always offered together: looking and
|
||||
applying are different intentions, and an operator who only
|
||||
wants to know whether anything is new must not be routed
|
||||
through a real deployment to find out.
|
||||
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
|
||||
the shell: a queued update does nothing visible until the agent
|
||||
picks it up, and while it runs the site is in maintenance mode
|
||||
and this page is unreachable. Both look identical to "wedged"
|
||||
without a time — hence the live countdown, replacing what used
|
||||
to be a static "spätestens um 17:19" that read as a promise
|
||||
nothing then visibly kept.
|
||||
|
||||
"Jetzt aktualisieren" now requires a released version to
|
||||
install. It used to be offered unconditionally, because
|
||||
`behind` was a reading taken up to a minute ago and a
|
||||
console that had not noticed the last commit yet would
|
||||
refuse to act on it. That reasoning belonged to counting
|
||||
commits on a branch, where the answer changes several times
|
||||
an hour. An update is a TAG now: it appears when somebody
|
||||
releases one, not when somebody pushes — and pressing the
|
||||
check button answers within a second, so a stale reading is
|
||||
no longer something to work around.
|
||||
There is no countdown any more, for either. It was wrong in
|
||||
fact — the target was recomputed on every poll, and where the
|
||||
interval the agent reported was shorter than the timer actually
|
||||
installed on that host it landed in the past every time and
|
||||
reset to a full minute, so it ran backwards a few seconds and
|
||||
jumped, forever. And it was wrong in kind: a check starts
|
||||
nothing, and an update now starts at once. A queued run says so
|
||||
in a sentence; a queued check says nothing beyond its badge. --}}
|
||||
{{-- And the step is NOT shown here.
|
||||
|
||||
Disabled for the cases where it would go nowhere: nothing
|
||||
released above the installed version, a run already in
|
||||
flight, no agent, or a check already pending (so a second
|
||||
click cannot collide with the first and be refused). --}}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if ($update['running'])
|
||||
<x-ui.button variant="secondary" :disabled="true">
|
||||
<x-ui.icon name="refresh" class="size-4 animate-spin" />
|
||||
{{ __('admin_settings.update_running') }}
|
||||
</x-ui.button>
|
||||
@elseif (! $update['agent_seen'])
|
||||
<x-ui.button variant="secondary" :disabled="true">{{ __('admin_settings.update_unknown') }}</x-ui.button>
|
||||
@else
|
||||
<x-ui.button variant="secondary" :disabled="$update['checking']"
|
||||
wire:click="requestCheck" wire:loading.attr="disabled" wire:target="requestCheck">
|
||||
<x-ui.icon name="refresh" class="size-4 {{ $update['checking'] ? 'animate-spin' : '' }}" />
|
||||
{{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="primary" :disabled="$update['checking'] || ! $update['available']"
|
||||
wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
{{ __('admin_settings.update_now') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
It used to be, in a small bordered block inside this card, and
|
||||
the moment a run began an operator saw it appear and then, up
|
||||
to three seconds later, saw the full-page overlay cover it and
|
||||
say the same thing in a different size. Two windows for one
|
||||
event. The overlay in layouts/admin carries the step, the
|
||||
running time and the log tail — it is the one that survives the
|
||||
restart, so it is the one that keeps them. --}}
|
||||
|
||||
{{-- The window. Deliberately inside the update card rather than a
|
||||
section of its own: it is the same decision as the button next
|
||||
to it, only taken in advance. --}}
|
||||
<div class="mt-5 border-t border-line pt-5">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" wire:model.live="autoUpdate" class="mt-0.5 size-4 rounded border-line text-accent-active">
|
||||
<span>
|
||||
<span class="block text-sm font-semibold text-ink">{{ __('admin_settings.auto_title') }}</span>
|
||||
<span class="mt-1 block max-w-[64ch] text-xs leading-relaxed text-muted">{{ __('admin_settings.auto_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 space-y-4 @unless ($autoUpdate) opacity-50 @endunless">
|
||||
<div>
|
||||
<span class="mb-2 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_days') }}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach ([1, 2, 3, 4, 5, 6, 7] as $day)
|
||||
<label class="cursor-pointer">
|
||||
<input type="checkbox" wire:model="autoDays" value="{{ $day }}" class="peer sr-only" @disabled(! $autoUpdate)>
|
||||
<span class="inline-flex min-h-9 items-center rounded border border-line bg-surface px-3 text-sm text-muted transition peer-checked:border-accent-border peer-checked:bg-accent-subtle peer-checked:font-semibold peer-checked:text-accent-text">
|
||||
{{ __('admin_settings.weekday.'.$day) }}
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('autoDays') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label for="auto-from" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_from') }}</label>
|
||||
<input id="auto-from" type="time" wire:model="autoFrom" @disabled(! $autoUpdate)
|
||||
class="min-h-10 w-32 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
|
||||
@error('autoFrom') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="auto-to" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_to') }}</label>
|
||||
<input id="auto-to" type="time" wire:model="autoTo" @disabled(! $autoUpdate)
|
||||
class="min-h-10 w-32 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
|
||||
@error('autoTo') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<x-ui.button wire:click="saveUpdateWindow" variant="secondary" size="sm">{{ __('admin_settings.save') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted">
|
||||
@if ($autoUpdate)
|
||||
{{ __('admin_settings.auto_next', [
|
||||
'days' => collect($autoDays)->sort()->map(fn ($d) => __('admin_settings.weekday.'.$d))->join(', '),
|
||||
'from' => $autoFrom,
|
||||
'to' => $autoTo,
|
||||
]) }}
|
||||
@else
|
||||
{{ __('admin_settings.auto_off') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $update['agent_seen'])
|
||||
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
|
||||
@elseif ($update['last_error'])
|
||||
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
|
||||
@elseif (! $update['available'] && ! $update['running'] && ! $update['checking'])
|
||||
{{-- Why the button is grey. Without this the console looks
|
||||
broken to whoever just pushed to main and expects to see
|
||||
something: it is not broken, it is waiting for a release. --}}
|
||||
<p class="mt-3 text-xs text-muted">{{ __('admin_settings.update_only_releases') }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
|
||||
the shell: a queued update does nothing visible until the agent
|
||||
picks it up, and while it runs the site is in maintenance mode
|
||||
and this page is unreachable. Both look identical to "wedged"
|
||||
without a time — hence the live countdown, replacing what used
|
||||
to be a static "spätestens um 17:19" that read as a promise
|
||||
nothing then visibly kept.
|
||||
|
||||
There is no countdown any more, for either. It was wrong in
|
||||
fact — the target was recomputed on every poll, and where the
|
||||
interval the agent reported was shorter than the timer actually
|
||||
installed on that host it landed in the past every time and
|
||||
reset to a full minute, so it ran backwards a few seconds and
|
||||
jumped, forever. And it was wrong in kind: a check starts
|
||||
nothing, and an update now starts at once. A queued run says so
|
||||
in a sentence; a queued check says nothing beyond its badge. --}}
|
||||
{{-- And the step is NOT shown here.
|
||||
|
||||
It used to be, in a small bordered block inside this card, and
|
||||
the moment a run began an operator saw it appear and then, up
|
||||
to three seconds later, saw the full-page overlay cover it and
|
||||
say the same thing in a different size. Two windows for one
|
||||
event. The overlay in layouts/admin carries the step, the
|
||||
running time and the log tail — it is the one that survives the
|
||||
restart, so it is the one that keeps them. --}}
|
||||
|
||||
{{-- The window. Deliberately inside the update card rather than a
|
||||
section of its own: it is the same decision as the button next
|
||||
to it, only taken in advance. --}}
|
||||
<div class="mt-5 border-t border-line pt-5">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" wire:model.live="autoUpdate" class="mt-0.5 size-4 rounded border-line text-accent-active">
|
||||
<span>
|
||||
<span class="block text-sm font-semibold text-ink">{{ __('admin_settings.auto_title') }}</span>
|
||||
<span class="mt-1 block max-w-[64ch] text-xs leading-relaxed text-muted">{{ __('admin_settings.auto_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 space-y-4 @unless ($autoUpdate) opacity-50 @endunless">
|
||||
<div>
|
||||
<span class="mb-2 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_days') }}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach ([1, 2, 3, 4, 5, 6, 7] as $day)
|
||||
<label class="cursor-pointer">
|
||||
<input type="checkbox" wire:model="autoDays" value="{{ $day }}" class="peer sr-only" @disabled(! $autoUpdate)>
|
||||
<span class="inline-flex min-h-9 items-center rounded border border-line bg-surface px-3 text-sm text-muted transition peer-checked:border-accent-border peer-checked:bg-accent-subtle peer-checked:font-semibold peer-checked:text-accent-text">
|
||||
{{ __('admin_settings.weekday.'.$day) }}
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('autoDays') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label for="auto-from" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_from') }}</label>
|
||||
<input id="auto-from" type="time" wire:model="autoFrom" @disabled(! $autoUpdate)
|
||||
class="min-h-10 w-32 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
|
||||
@error('autoFrom') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="auto-to" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_to') }}</label>
|
||||
<input id="auto-to" type="time" wire:model="autoTo" @disabled(! $autoUpdate)
|
||||
class="min-h-10 w-32 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
|
||||
@error('autoTo') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<x-ui.button wire:click="saveUpdateWindow" variant="secondary" size="sm">{{ __('admin_settings.save') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted">
|
||||
@if ($autoUpdate)
|
||||
{{ __('admin_settings.auto_next', [
|
||||
'days' => collect($autoDays)->sort()->map(fn ($d) => __('admin_settings.weekday.'.$d))->join(', '),
|
||||
'from' => $autoFrom,
|
||||
'to' => $autoTo,
|
||||
]) }}
|
||||
@else
|
||||
{{ __('admin_settings.auto_off') }}
|
||||
@if ($update['last_state'] !== null && ! $update['running'])
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
{{ __('admin_settings.update_last_run', [
|
||||
'state' => __('admin_settings.update_state.'.$update['last_state']),
|
||||
'when' => $update['last_finished_at']?->diffForHumans() ?? '—',
|
||||
]) }}
|
||||
@if ($update['last_started_at'] && $update['last_finished_at'])
|
||||
· {{ __('admin_settings.update_took', ['duration' => $update['last_started_at']->diffForHumans($update['last_finished_at'], true)]) }}
|
||||
@endif
|
||||
{{-- Only on a failure: on a run that worked the last step is
|
||||
"left maintenance mode", which says nothing. --}}
|
||||
@if ($update['last_state'] === 'failed' && $update['last_phase'])
|
||||
· {{ __('admin_settings.update_failed_at', ['step' => $update['last_phase']]) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($update['last_state'] !== null && ! $update['running'])
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
{{ __('admin_settings.update_last_run', [
|
||||
'state' => __('admin_settings.update_state.'.$update['last_state']),
|
||||
'when' => $update['last_finished_at']?->diffForHumans() ?? '—',
|
||||
]) }}
|
||||
@if ($update['last_started_at'] && $update['last_finished_at'])
|
||||
· {{ __('admin_settings.update_took', ['duration' => $update['last_started_at']->diffForHumans($update['last_finished_at'], true)]) }}
|
||||
@endif
|
||||
{{-- Only on a failure: on a run that worked the last step is
|
||||
"left maintenance mode", which says nothing. --}}
|
||||
@if ($update['last_state'] === 'failed' && $update['last_phase'])
|
||||
· {{ __('admin_settings.update_failed_at', ['step' => $update['last_phase']]) }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Open while it runs: "läuft gerade" on its own tells an operator
|
||||
nothing about whether it is progressing or wedged. --}}
|
||||
@if ($updateLog)
|
||||
<details class="mt-4" @if ($update['running']) open @endif>
|
||||
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
{{-- Website visibility and two-factor policy stack in the left
|
||||
column: both are short, and the console's allow-list beside
|
||||
them is long. Three cards of one row each left a column of
|
||||
white space down the middle of the page. --}}
|
||||
<div class="space-y-5">
|
||||
{{-- My account --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.site_title') }}</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $sitePublic ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 max-w-xl text-sm text-muted">
|
||||
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
|
||||
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
|
||||
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled" wire:target="toggleSiteVisibility">
|
||||
<x-ui.icon :name="$sitePublic ? 'lock' : 'unlock'" class="size-4" />
|
||||
{{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
{{-- Open while it runs: "läuft gerade" on its own tells an operator
|
||||
nothing about whether it is progressing or wedged. --}}
|
||||
@if ($updateLog)
|
||||
<details class="mt-4" @if ($update['running']) open @endif>
|
||||
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
{{-- Two-factor policy — voluntary by default, the Owner can make it
|
||||
compulsory. The switch refuses to turn on while the operator flipping
|
||||
it has no confirmed two-factor themselves: the page that would turn it
|
||||
back off sits behind the switch. --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:45ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.two_factor_title') }}</h2>
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('admin_settings.two_factor_body') }}</p>
|
||||
|
||||
<form wire:submit="saveTwoFactorPolicy" class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-body">
|
||||
<input type="checkbox" wire:model="requireTwoFactor" class="size-4 rounded border-line-strong text-accent" />
|
||||
{{ __('admin_settings.two_factor_require') }}
|
||||
</label>
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="saveTwoFactorPolicy">
|
||||
{{ __('admin_settings.save') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
@error('requireTwoFactor')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Who may reach this console --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.console_title') }}</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $consoleRestricted ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">
|
||||
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
|
||||
</p>
|
||||
<p class="mt-2 font-mono text-xs text-muted">
|
||||
{{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}
|
||||
</p>
|
||||
</div>
|
||||
<x-ui.button :variant="$consoleRestricted ? 'secondary' : 'primary'"
|
||||
wire:click="toggleConsoleRestriction" wire:loading.attr="disabled" wire:target="toggleConsoleRestriction">
|
||||
<x-ui.icon :name="$consoleRestricted ? 'unlock' : 'lock'" class="size-4" />
|
||||
{{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-2 border-t border-line pt-4">
|
||||
<p class="text-xs font-semibold text-muted">{{ __('admin_settings.console_always') }}</p>
|
||||
@foreach ($consoleVpnRanges as $range)
|
||||
<div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
|
||||
<span class="font-mono text-sm text-body">{{ $range }}</span>
|
||||
<span class="text-xs text-muted">{{ __('admin_settings.console_vpn_note') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<p class="pt-3 text-xs font-semibold text-muted">{{ __('admin_settings.console_extra') }}</p>
|
||||
@forelse ($consoleIps as $ip)
|
||||
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
|
||||
<span class="font-mono text-sm text-body">{{ $ip }}</span>
|
||||
<x-ui.button variant="ghost" size="sm" wire:click="removeConsoleIp('{{ $ip }}')">
|
||||
{{ __('admin_settings.console_remove') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-muted">{{ __('admin_settings.console_none') }}</p>
|
||||
@endforelse
|
||||
|
||||
{{-- The hint sits under the row, not inside it: as a sibling of
|
||||
the field it made the flex row as tall as field-plus-hint,
|
||||
and the button stretched to match. --}}
|
||||
<form wire:submit="addConsoleIp" class="pt-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<x-ui.input name="consoleIp" wire:model="consoleIp" placeholder="203.0.113.7" />
|
||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
{{-- Website visibility and two-factor policy stack in the left
|
||||
column: both are short, and the console's allow-list beside
|
||||
them is long. Three cards of one row each left a column of
|
||||
white space down the middle of the page. --}}
|
||||
<div class="space-y-5">
|
||||
{{-- My account --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.site_title') }}</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $sitePublic ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }}
|
||||
</span>
|
||||
</div>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
<p class="mt-1.5 max-w-xl text-sm text-muted">
|
||||
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
|
||||
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
|
||||
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('admin_settings.console_ip_hint') }}</p>
|
||||
</form>
|
||||
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled" wire:target="toggleSiteVisibility">
|
||||
<x-ui.icon :name="$sitePublic ? 'lock' : 'unlock'" class="size-4" />
|
||||
{{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Two-factor policy — voluntary by default, the Owner can make it
|
||||
compulsory. The switch refuses to turn on while the operator flipping
|
||||
it has no confirmed two-factor themselves: the page that would turn it
|
||||
back off sits behind the switch. --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:45ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.two_factor_title') }}</h2>
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('admin_settings.two_factor_body') }}</p>
|
||||
|
||||
<form wire:submit="saveTwoFactorPolicy" class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-body">
|
||||
<input type="checkbox" wire:model="requireTwoFactor" class="size-4 rounded border-line-strong text-accent" />
|
||||
{{ __('admin_settings.two_factor_require') }}
|
||||
</label>
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="saveTwoFactorPolicy">
|
||||
{{ __('admin_settings.save') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
@error('requireTwoFactor')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Who may reach this console --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.console_title') }}</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $consoleRestricted ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">
|
||||
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
|
||||
</p>
|
||||
<p class="mt-2 font-mono text-xs text-muted">
|
||||
{{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}
|
||||
</p>
|
||||
</div>
|
||||
<x-ui.button :variant="$consoleRestricted ? 'secondary' : 'primary'"
|
||||
wire:click="toggleConsoleRestriction" wire:loading.attr="disabled" wire:target="toggleConsoleRestriction">
|
||||
<x-ui.icon :name="$consoleRestricted ? 'unlock' : 'lock'" class="size-4" />
|
||||
{{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<div class="mt-5 space-y-2 border-t border-line pt-4">
|
||||
<p class="text-xs font-semibold text-muted">{{ __('admin_settings.console_always') }}</p>
|
||||
@foreach ($consoleVpnRanges as $range)
|
||||
<div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
|
||||
<span class="font-mono text-sm text-body">{{ $range }}</span>
|
||||
<span class="text-xs text-muted">{{ __('admin_settings.console_vpn_note') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<p class="pt-3 text-xs font-semibold text-muted">{{ __('admin_settings.console_extra') }}</p>
|
||||
@forelse ($consoleIps as $ip)
|
||||
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
|
||||
<span class="font-mono text-sm text-body">{{ $ip }}</span>
|
||||
<x-ui.button variant="ghost" size="sm" wire:click="removeConsoleIp('{{ $ip }}')">
|
||||
{{ __('admin_settings.console_remove') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-muted">{{ __('admin_settings.console_none') }}</p>
|
||||
@endforelse
|
||||
|
||||
{{-- The hint sits under the row, not inside it: as a sibling of
|
||||
the field it made the flex row as tall as field-plus-hint,
|
||||
and the button stretched to match. --}}
|
||||
<form wire:submit="addConsoleIp" class="pt-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<x-ui.input name="consoleIp" wire:model="consoleIp" placeholder="203.0.113.7" />
|
||||
</div>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('admin_settings.console_ip_hint') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- ── Das eigene Konto ────────────────────────────────────────────── --}}
|
||||
<section class="space-y-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="lbl">{{ __('admin_settings.group.account') }}</h2>
|
||||
<span class="h-px flex-1 bg-line"></span>
|
||||
@if ($tab === 'account')
|
||||
<section class="space-y-5">
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
|
||||
<div class="grid gap-4">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
|
||||
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Own password. There was no way to change one at all: an account created
|
||||
with a generated password kept it until someone opened a shell. --}}
|
||||
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
|
||||
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
|
||||
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
|
||||
:label="__('admin_settings.password_new')" wire:model="newPassword" />
|
||||
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
|
||||
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Every operator sees their own, with no capability required: this is
|
||||
the account you are signed in as, and being unable to see where else
|
||||
that is true is the gap the feature exists to close. --}}
|
||||
<div class="animate-rise [animation-delay:40ms]">
|
||||
@livewire('admin.sessions')
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- ── Zwei-Faktor ──────────────────────────────────────────────────────
|
||||
The unchanged Admin\TwoFactorSetup component, rendered here instead of
|
||||
behind a navigation entry of its own. It is a setting, and it was the
|
||||
one thing an operator had to leave the settings page to change about
|
||||
their own access.
|
||||
|
||||
It still answers at its own route as well, because
|
||||
RequireOperatorTwoFactor sends an operator there who may not open
|
||||
anything else yet — this page included. Same component either way, so
|
||||
there is no second copy of the enrolment logic to keep in step. --}}
|
||||
@if ($tab === 'two-factor')
|
||||
<div class="animate-rise">
|
||||
@livewire('admin.two-factor-setup', ['embedded' => true])
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
|
||||
<div class="grid gap-4">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
|
||||
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Own password. There was no way to change one at all: an account created
|
||||
with a generated password kept it until someone opened a shell. --}}
|
||||
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
|
||||
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
|
||||
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
|
||||
:label="__('admin_settings.password_new')" wire:model="newPassword" />
|
||||
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
|
||||
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{{-- Every operator sees their own, with no capability required: this is
|
||||
the account you are signed in as, and being unable to see where else
|
||||
that is true is the gap the feature exists to close. --}}
|
||||
<div class="animate-rise [animation-delay:40ms]">
|
||||
@livewire('admin.sessions')
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- ── Das Team ─────────────────────────────────────────────────────── --}}
|
||||
@if ($canManageStaff)
|
||||
@if ($tab === 'team' && $canManageStaff)
|
||||
<section class="space-y-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="lbl">{{ __('admin_settings.group.team') }}</h2>
|
||||
<span class="h-px flex-1 bg-line"></span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div @class(['space-y-6', 'mx-auto max-w-3xl' => ! $embedded])>
|
||||
{{-- The state belongs beside the title, not floating inside the card: it is
|
||||
a property of the account, not of whichever step is on screen. --}}
|
||||
a property of the account, not of whichever step is on screen.
|
||||
|
||||
As a tab of the settings page the title steps down to an h2: the page
|
||||
already has its own, and two <h1> elements is two page titles. The
|
||||
state badge stays either way — it is the first thing somebody opening
|
||||
this comes to find out. --}}
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h1>
|
||||
@if ($embedded)
|
||||
<h2 class="text-lg font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h2>
|
||||
@else
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h1>
|
||||
@endif
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('two_factor_setup.sub') }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Settings;
|
||||
use App\Support\Navigation;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The settings page is tabbed, and the tab survives a reload.
|
||||
*
|
||||
* It had grown to three headed sections in one column with the operator's own
|
||||
* password somewhere in the middle. Which part somebody wants is a property of
|
||||
* what they came to do, not of how far they scroll — and the installation tab
|
||||
* refreshes itself while an update runs, so a tab kept only in the component
|
||||
* would drop them back to the first one mid-deployment.
|
||||
*/
|
||||
it('opens the installation tab by default and keeps it out of the address bar', function () {
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSet('tab', 'installation')
|
||||
->assertSee(__('admin_settings.update_title'));
|
||||
});
|
||||
|
||||
it('remembers the tab across a reload, because it is in the URL', function () {
|
||||
// The whole point of #[Url]. Without it a refresh — or the browser's back
|
||||
// button — lands on the first tab every time.
|
||||
Livewire::withQueryParams(['tab' => 'account'])
|
||||
->actingAs(operator('Owner'), 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSet('tab', 'account')
|
||||
->assertSee(__('admin_settings.account_title'))
|
||||
->assertDontSee(__('admin_settings.update_title'));
|
||||
});
|
||||
|
||||
it('falls back to the first tab when the URL names one that does not exist', function () {
|
||||
// A query string is a string a stranger typed. Anything unknown must not
|
||||
// render a settings page with no section on it at all.
|
||||
Livewire::withQueryParams(['tab' => 'wat'])
|
||||
->actingAs(operator('Owner'), 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSet('tab', 'installation')
|
||||
->assertSee(__('admin_settings.update_title'));
|
||||
});
|
||||
|
||||
it('carries two-factor enrolment as a tab rather than a page of its own', function () {
|
||||
Livewire::withQueryParams(['tab' => 'two-factor'])
|
||||
->actingAs(operator('Owner'), 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSet('tab', 'two-factor')
|
||||
->assertSee(__('two_factor_setup.title'));
|
||||
});
|
||||
|
||||
it('does not offer the team tab to somebody who cannot manage staff', function () {
|
||||
// A tab that raises a 403 is worse than one that is not there — and an
|
||||
// operator who reaches ?tab=team anyway lands on the first tab rather than
|
||||
// on a page with nothing on it.
|
||||
$support = operator('Support');
|
||||
|
||||
expect($support->can('staff.manage'))->toBeFalse();
|
||||
|
||||
Livewire::withQueryParams(['tab' => 'team'])
|
||||
->actingAs($support, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSet('tab', 'installation')
|
||||
->assertViewHas('tabs', fn (array $tabs) => ! in_array('team', $tabs, true));
|
||||
});
|
||||
|
||||
it('lists every tab it can render, and renders every tab it lists', function () {
|
||||
// The const IS the schema: it validates the query string, builds the bar
|
||||
// and decides what renders. A tab in the list with no label is a button
|
||||
// that says "admin_settings.tab.x".
|
||||
foreach (Settings::TABS as $tab) {
|
||||
$key = 'admin_settings.tab.'.str_replace('-', '_', $tab);
|
||||
|
||||
expect(__($key))->not->toBe($key);
|
||||
}
|
||||
});
|
||||
|
||||
it('moves the tunnel to System and drops the two-factor entry from the menu', function () {
|
||||
// VPN is not a task of the day's operations — it is how the console reaches
|
||||
// the estate at all, so it belongs beside the other things that have to
|
||||
// work first. Two-factor left the menu entirely: it is a tab now.
|
||||
$groups = collect(Navigation::console());
|
||||
|
||||
$routesIn = fn (string $label) => $groups
|
||||
->firstWhere('label', __($label))['items'] ?? [];
|
||||
|
||||
$system = collect($routesIn('admin.nav_group.system'))->pluck(0);
|
||||
$operations = collect($routesIn('admin.nav_group.operations'))->pluck(0);
|
||||
|
||||
expect($system)->toContain('admin.vpn')
|
||||
->and($operations)->not->toContain('admin.vpn')
|
||||
->and($groups->pluck('items')->flatten(1)->pluck(0))
|
||||
->not->toContain('admin.two-factor-setup');
|
||||
});
|
||||
|
||||
it('keeps the standalone enrolment route, because the gate sends people there', function () {
|
||||
// RequireOperatorTwoFactor redirects an operator who has not enrolled yet,
|
||||
// and that operator may not open anything else — the settings page
|
||||
// included. Folding the page away entirely would have locked them out.
|
||||
expect(route('admin.two-factor-setup'))->toBeString();
|
||||
|
||||
$this->actingAs(operator('Owner'), 'operator')
|
||||
->get(route('admin.two-factor-setup'))
|
||||
->assertOk()
|
||||
->assertSee(__('two_factor_setup.title'));
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
use App\Support\CompanyProfile;
|
||||
use App\Support\Settings;
|
||||
|
||||
/**
|
||||
* The public sheet quotes what a person pays, not what a business books.
|
||||
*
|
||||
* A private customer has no way to add 20 % in their head and no right to be
|
||||
* surprised by it at checkout; a business gets the VAT back either way and
|
||||
* needs the net figure on the invoice, not on the poster. So the big number is
|
||||
* gross and the net one stands under it.
|
||||
*/
|
||||
it('quotes packages including VAT, with the net figure underneath', function () {
|
||||
$content = $this->get('/')->assertOk()->getContent();
|
||||
|
||||
// start is 49 € net in the catalogue → 58,80 € at 20 %.
|
||||
expect($content)->toContain('58,80'."\u{00A0}".'€')
|
||||
->and($content)->toContain('49'."\u{00A0}".'€')
|
||||
->and($content)->toContain('inkl. 20 % MwSt.');
|
||||
});
|
||||
|
||||
it('follows the rate the operator set, not one written into the page', function () {
|
||||
// The rate is console-editable (Finance page) and the same one an invoice
|
||||
// uses. A percentage written into the template is the second source that
|
||||
// makes the sheet and the invoice disagree.
|
||||
Settings::set('company.tax_rate', 10.0);
|
||||
|
||||
$content = $this->get('/')->assertOk()->getContent();
|
||||
|
||||
expect($content)->toContain('53,90'."\u{00A0}".'€') // 49 € + 10 %
|
||||
->and($content)->toContain('inkl. 10 % MwSt.')
|
||||
->and($content)->not->toContain('58,80'."\u{00A0}".'€');
|
||||
});
|
||||
|
||||
it('drops the fractional part of a rate that has none', function () {
|
||||
// "20 %" — not "20,0 %". The label is read by a person, not parsed.
|
||||
Settings::set('company.tax_rate', 20.0);
|
||||
|
||||
expect($this->get('/')->getContent())->not->toContain('20,0 %');
|
||||
});
|
||||
|
||||
it('names what the setup costs instead of only announcing that it costs', function () {
|
||||
// The sheet said "zzgl. einmaliger Einrichtung" for months without naming a
|
||||
// figure, which leaves a visitor knowing only that there is one.
|
||||
Settings::set('company.setup_fee_cents', 9900);
|
||||
|
||||
expect($this->get('/')->assertOk()->getContent())
|
||||
->toContain('zzgl. Einrichtung einmalig')
|
||||
->toContain('118,80'."\u{00A0}".'€'); // 99 € + 20 %
|
||||
});
|
||||
|
||||
it('says nothing about a setup fee that is not charged', function () {
|
||||
// Zero is not "0 €" on a price sheet — it is a sentence that should not be
|
||||
// there at all.
|
||||
Settings::set('company.setup_fee_cents', 0);
|
||||
|
||||
expect($this->get('/')->assertOk()->getContent())
|
||||
->not->toContain('zzgl. Einrichtung einmalig');
|
||||
});
|
||||
|
||||
it('reads the fee back in euro after an operator types it in euro', function () {
|
||||
// The form is in euro and the store is in cents. Asking an operator to type
|
||||
// 9900 for ninety-nine euro is how a fee ends up a hundred times too large.
|
||||
Settings::set('company.setup_fee_cents', 9995);
|
||||
|
||||
expect(CompanyProfile::setupFeeCents())->toBe(9995)
|
||||
->and(number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', ''))->toBe('99.95');
|
||||
});
|
||||
|
||||
it('does not admit to a setup cost the installation does not charge', function () {
|
||||
// The comparison card listed "Einrichtung kostet einmalig" as the honest
|
||||
// downside of the offer. With no fee configured that is a downside we do
|
||||
// not have, stated on our own page.
|
||||
Settings::set('company.setup_fee_cents', 0);
|
||||
|
||||
expect($this->get('/')->assertOk()->getContent())
|
||||
->not->toContain('Einrichtung kostet einmalig');
|
||||
|
||||
Settings::set('company.setup_fee_cents', 9900);
|
||||
|
||||
expect($this->get('/')->assertOk()->getContent())
|
||||
->toContain('Einrichtung kostet einmalig 118,80'."\u{00A0}".'€');
|
||||
});
|
||||
|
|
@ -28,6 +28,24 @@ use Illuminate\Support\Str;
|
|||
* rund um die Uhr" is a promise above the table and a card bullet beside it,
|
||||
* and the point of the rebuild is that it is no longer a row inside it.
|
||||
*/
|
||||
/**
|
||||
* A net catalogue figure as the page prints it: gross, in the page's format.
|
||||
*
|
||||
* Computed here rather than written down, for the same reason the page does not
|
||||
* write it down — the rate is console-editable, and a "9 €" in a test is a test
|
||||
* that passes on the day somebody changes the VAT rate and the page goes wrong.
|
||||
*/
|
||||
function priceSheetGross(int $netCents): string
|
||||
{
|
||||
$cents = (int) round($netCents * (1 + App\Support\CompanyProfile::taxRate() / 100));
|
||||
|
||||
$amount = $cents % 100 === 0
|
||||
? number_format($cents / 100, 0, ',', '.')
|
||||
: number_format($cents / 100, 2, ',', '.');
|
||||
|
||||
return $amount."\u{00A0}€";
|
||||
}
|
||||
|
||||
function priceSheetTable(string $html): string
|
||||
{
|
||||
$section = substr($html, (int) strpos($html, 'id="preise"'));
|
||||
|
|
@ -173,7 +191,7 @@ it('offers what a plan lacks at the price the module catalogue would charge', fu
|
|||
// The third cell state, and the reason it exists: team does not carry an
|
||||
// own domain, but we sell one — a dash there was the page telling a visitor
|
||||
// no on a sale we would happily make.
|
||||
$this->get('/')->assertOk()->assertSee("optional · 9\u{00A0}€", false);
|
||||
$this->get('/')->assertOk()->assertSee('optional · '.priceSheetGross(900), false);
|
||||
|
||||
// And the figure follows the catalogue. Written into the template it would
|
||||
// be right until the first time somebody changed the price and read the
|
||||
|
|
@ -182,8 +200,8 @@ it('offers what a plan lacks at the price the module catalogue would charge', fu
|
|||
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertSee("optional · 19\u{00A0}€", false)
|
||||
->assertDontSee("optional · 9\u{00A0}€", false);
|
||||
->assertSee('optional · '.priceSheetGross(1900), false)
|
||||
->assertDontSee('optional · '.priceSheetGross(900), false);
|
||||
});
|
||||
|
||||
it('states the own-domain rule package by package: impossible, optional, included', function () {
|
||||
|
|
@ -199,7 +217,7 @@ it('states the own-domain rule package by package: impossible, optional, include
|
|||
->and($cells[0])->toContain('—')
|
||||
->and($cells[0])->not->toContain('optional')
|
||||
->and($cells[0])->not->toContain('€')
|
||||
->and($cells[1])->toContain("optional · 9\u{00A0}€")
|
||||
->and($cells[1])->toContain('optional · '.priceSheetGross(900))
|
||||
->and($cells[2])->toContain('inklusive')
|
||||
->and($cells[3])->toContain('inklusive');
|
||||
});
|
||||
|
|
@ -248,7 +266,9 @@ it('names the modules it sells, with the prices the catalogue charges for them',
|
|||
// The complaint that started this: the shop sold five modules and the
|
||||
// public page mentioned none of them.
|
||||
$storage = (int) config('provisioning.storage_addon.gb');
|
||||
$money = fn (int $cents) => number_format($cents / 100, 0, ',', '.')."\u{00A0}€";
|
||||
// Gross, because that is what the sheet quotes — a private customer must
|
||||
// never be shown a figure that grows at checkout.
|
||||
$money = fn (int $cents) => priceSheetGross($cents);
|
||||
|
||||
$page = $this->get('/')->assertOk();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue