mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(permissions): add granular permissions for train management actions
feat(train-builder): update permissions checks for train actions in UI feat(contracts): enhance contract view and shipment request pages with new features test(shipment-preview): add tests for customs clearing logic in booking preview style(contract-sign-bar): create CSS for fixed sign bar layout
This commit is contained in:
@@ -1974,6 +1974,11 @@ export class ContractBookingService {
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
isGovernment: contract.isGovernment,
|
||||
// The clearance fee is gated on this flag in BookingPricingService, and
|
||||
// createUnderContract copies it off the contract. Omitting it here priced
|
||||
// the preview WITHOUT the customs line the created booking is then billed
|
||||
// — the customer confirmed one total and got invoiced a larger one.
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/**
|
||||
* The price the customer confirms in the modal comes from validateShipment,
|
||||
* which prices an UNSAVED twin of the booking createUnderContract will write.
|
||||
* Any contract field that reaches the pricing service must be copied onto that
|
||||
* twin — a field left off doesn't fail loudly, it silently drops whole charge
|
||||
* lines from the quote while the created booking is still billed for them.
|
||||
*
|
||||
* The regression this locks: `customsClearingEnabled` was missing, so
|
||||
* BookingPricingService's `if (booking.customsClearingEnabled)` gate never
|
||||
* opened in the preview. Container bookings quoted rail freight alone, then
|
||||
* invoiced rail + customs clearance.
|
||||
*/
|
||||
describe('shipment preview / created booking parity', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'c1',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'EXPORT',
|
||||
paymentCurrency: 'ETB',
|
||||
serviceTypeId: 'svc1',
|
||||
customsClearingEnabled: true,
|
||||
equipmentReturn: 'NO_RETURN',
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
isGovernment: false,
|
||||
cargoScope: [],
|
||||
firstMilePickupAddress: null,
|
||||
lastMileDeliveryAddress: null,
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
/**
|
||||
* Run validateShipment against stubbed collaborators and hand back the
|
||||
* booking the pricing service was actually asked to price.
|
||||
*/
|
||||
const previewBookingFor = async (c: Contract): Promise<Booking> => {
|
||||
let priced: Booking | null = null;
|
||||
|
||||
const svc = {
|
||||
contractsRepository: { findByIdWithRelations: async () => c },
|
||||
bookingPricingService: {
|
||||
computePriceForBooking: async (b: Booking) => {
|
||||
priced = b;
|
||||
return {
|
||||
lineItems: [],
|
||||
totalAmount: 0,
|
||||
currency: 'ETB',
|
||||
overweightLines: [],
|
||||
hardBlocked: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
ruleEngineService: { capacityViolations: async () => [] },
|
||||
resolveRoute: async () => null,
|
||||
resolveShipmentCurrency: () => 'ETB',
|
||||
resolveCargoTypeId: () => null,
|
||||
resolveShipmentHandlingFlag: () => false,
|
||||
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
||||
resolveBulkTons: () => 0,
|
||||
resolveBulkWeightTons: () => 0,
|
||||
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
||||
handlingCounts: () => ({
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
returnQuantity: 0,
|
||||
}),
|
||||
max20ftPairDiffTons: async () => 2,
|
||||
findContainerClashesOnTrain: async () => [],
|
||||
};
|
||||
|
||||
const dto = {
|
||||
containers: [
|
||||
{
|
||||
containerSize: '40ft',
|
||||
quantity: 2,
|
||||
units: [{ vgmTons: 10 }, { vgmTons: 10 }],
|
||||
},
|
||||
],
|
||||
} as unknown as CreateBookingUnderContractDto;
|
||||
|
||||
await (
|
||||
ContractBookingService.prototype as unknown as {
|
||||
validateShipment: (
|
||||
this: unknown,
|
||||
id: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
).validateShipment.call(svc, 'c1', dto);
|
||||
|
||||
if (!priced) throw new Error('pricing service was never called');
|
||||
return priced;
|
||||
};
|
||||
|
||||
it('prices the preview with customs clearing on when the contract clears', async () => {
|
||||
// Without this the clearance fee is quoted as 0 and billed in full later.
|
||||
const booking = await previewBookingFor(contract());
|
||||
expect(booking.customsClearingEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves customs clearing off when the contract does not clear', async () => {
|
||||
const booking = await previewBookingFor(
|
||||
contract({ customsClearingEnabled: false }),
|
||||
);
|
||||
expect(booking.customsClearingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the contract mile legs so trucking is quoted too', async () => {
|
||||
const booking = await previewBookingFor(
|
||||
contract({
|
||||
firstMilePickupAddress: 'Modjo Dry Port',
|
||||
lastMileDeliveryAddress: 'Djibouti Port',
|
||||
}),
|
||||
);
|
||||
expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port');
|
||||
expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port');
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,10 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.update,
|
||||
FREIGHT_PERMS.trains.assignWagons,
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
FREIGHT_PERMS.trains.changeLocomotives,
|
||||
FREIGHT_PERMS.trains.changeYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
@@ -72,7 +76,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
||||
setLocomotives(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -94,7 +98,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@FleetManage(FREIGHT_PERMS.trains.changeYard)
|
||||
@ApiOperation({
|
||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||
})
|
||||
@@ -143,7 +147,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
|
||||
@ApiOperation({
|
||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||
})
|
||||
@@ -152,14 +156,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
|
||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.activate(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||
@FleetManage(FREIGHT_PERMS.trains.disband)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:trains:assign_wagons",
|
||||
"Assign wagons to train",
|
||||
),
|
||||
// Granular splits of trains:update / trains:delete for the train-builder
|
||||
// detail page's Actions menu — each item gets its own grant instead of
|
||||
// sharing the coarse update/delete keys.
|
||||
perm(
|
||||
"e1c00001-0001-4000-8000-000000000006",
|
||||
"edr_freight_app:trains:change_locomotives",
|
||||
"Change train locomotives",
|
||||
),
|
||||
perm(
|
||||
"e1c00001-0001-4000-8000-000000000007",
|
||||
"edr_freight_app:trains:change_yard",
|
||||
"Change train yard",
|
||||
),
|
||||
perm(
|
||||
"e1c00001-0001-4000-8000-000000000008",
|
||||
"edr_freight_app:trains:toggle_active",
|
||||
"Activate or deactivate train",
|
||||
),
|
||||
perm(
|
||||
"e1c00001-0001-4000-8000-000000000009",
|
||||
"edr_freight_app:trains:disband",
|
||||
"Disband train",
|
||||
),
|
||||
perm(
|
||||
"e1d00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:routes:view",
|
||||
@@ -1714,6 +1737,10 @@ export const FREIGHT_PERMS = {
|
||||
update: "edr_freight_app:trains:update",
|
||||
delete: "edr_freight_app:trains:delete",
|
||||
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||
changeLocomotives: "edr_freight_app:trains:change_locomotives",
|
||||
changeYard: "edr_freight_app:trains:change_yard",
|
||||
toggleActive: "edr_freight_app:trains:toggle_active",
|
||||
disband: "edr_freight_app:trains:disband",
|
||||
},
|
||||
routes: {
|
||||
view: "edr_freight_app:routes:view",
|
||||
@@ -2021,6 +2048,10 @@ const FLEET_GRANULAR_KEYS: string[] = [
|
||||
FREIGHT_PERMS.trains.update,
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
FREIGHT_PERMS.trains.assignWagons,
|
||||
FREIGHT_PERMS.trains.changeLocomotives,
|
||||
FREIGHT_PERMS.trains.changeYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.routes.view,
|
||||
FREIGHT_PERMS.routes.create,
|
||||
FREIGHT_PERMS.routes.update,
|
||||
|
||||
@@ -188,6 +188,11 @@ export const FREIGHT_PERMS = {
|
||||
update: "edr_freight_app:trains:update",
|
||||
delete: "edr_freight_app:trains:delete",
|
||||
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||
/** Train-builder detail Actions menu — each item its own grant. */
|
||||
changeLocomotives: "edr_freight_app:trains:change_locomotives",
|
||||
changeYard: "edr_freight_app:trains:change_yard",
|
||||
toggleActive: "edr_freight_app:trains:toggle_active",
|
||||
disband: "edr_freight_app:trains:disband",
|
||||
},
|
||||
routes: {
|
||||
view: "edr_freight_app:routes:view",
|
||||
|
||||
@@ -47,7 +47,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
|
||||
@@ -83,9 +83,11 @@ export default function TrainBuilderDetailPage() {
|
||||
const [maintenanceTarget, setMaintenanceTarget] =
|
||||
useState<TrainCompositionWagon | null>(null);
|
||||
const { user } = useAuth();
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
const canDelete = canFleetAction(user, "trains", "delete");
|
||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
||||
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
|
||||
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
@@ -172,7 +174,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
canUpdate || canDelete ? (
|
||||
canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? (
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||
@@ -180,47 +182,49 @@ export default function TrainBuilderDetailPage() {
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{canUpdate ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
{composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
disabled={blockingLocomotives.length > 0}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
{canChangeLocomotives ? (
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
{canChangeYard ? (
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{canToggleActive ? (
|
||||
composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
disabled={blockingLocomotives.length > 0}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)
|
||||
) : null}
|
||||
{canDisband ? (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
@@ -277,7 +281,7 @@ export default function TrainBuilderDetailPage() {
|
||||
.
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{canUpdate ? (
|
||||
{canChangeLocomotives ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
|
||||
@@ -33,6 +33,8 @@ import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
import "./contract-sign-bar.css";
|
||||
|
||||
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
|
||||
|
||||
/**
|
||||
@@ -225,7 +227,7 @@ export default function ContractViewPage() {
|
||||
return (
|
||||
<Box
|
||||
p={{ base: "md", md: "xl" }}
|
||||
pb={data.canSignCustomer ? 120 : undefined}
|
||||
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||
@@ -294,16 +296,19 @@ export default function ContractViewPage() {
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
left: "var(--sign-bar-left, 0px)",
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
borderTop: "1px solid var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-body)",
|
||||
paddingBottom: 32,
|
||||
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
className="contract-sign-bar"
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<Stack gap="sm">
|
||||
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
|
||||
@@ -315,17 +320,15 @@ export default function ContractViewPage() {
|
||||
: "Read the full contract above before you can agree and sign."
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
disabled={!canProceedToSign}
|
||||
onClick={openSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
disabled={!canProceedToSign}
|
||||
onClick={openSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() {
|
||||
contract.contractKind === "GENERAL" &&
|
||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||
// Export shipments are invoiced in ETB only — USD is not offered.
|
||||
const isExport = contract.tradeDirection === "EXPORT";
|
||||
|
||||
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
||||
const SIZE_ORDER = ["20ft", "40ft"];
|
||||
@@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() {
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
paymentCurrency: isIntercity ? "ETB" : paymentCurrency,
|
||||
paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency,
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() {
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
{isIntercity
|
||||
? "Intercity shipments are invoiced in ETB."
|
||||
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
||||
: isExport
|
||||
? "Export shipments are invoiced in ETB."
|
||||
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={isIntercity ? "ETB" : paymentCurrency}
|
||||
value={isIntercity || isExport ? "ETB" : paymentCurrency}
|
||||
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
||||
disabled={isIntercity}
|
||||
data={[
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
]}
|
||||
disabled={isIntercity || isExport}
|
||||
data={
|
||||
isExport
|
||||
? [{ label: "ETB", value: "ETB" }]
|
||||
: [
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
]
|
||||
}
|
||||
color="teal"
|
||||
radius={10}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Keeps the fixed sign bar confined to the content area (right of the
|
||||
navbar) instead of spanning the full viewport and drifting off-center. */
|
||||
.contract-sign-bar {
|
||||
--sign-bar-left: 0px;
|
||||
}
|
||||
|
||||
@media (min-width: 48em) {
|
||||
.contract-sign-bar {
|
||||
--sign-bar-left: 260px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user