jaspr_unrouter 0.1.0 copy "jaspr_unrouter: ^0.1.0" to clipboard
jaspr_unrouter: ^0.1.0 copied to clipboard

Jaspr adapter for the unrouter typed routing core.

example/lib/main.dart

import 'dart:async';

import 'package:jaspr/dom.dart' as dom;
import 'package:jaspr/server.dart';
import 'package:jaspr_unrouter/jaspr_unrouter.dart';

final StoreSession _session = StoreSession();

final Unrouter<AppRoute> _router = createRouter();

final List<dom.StyleRule> _styles = <dom.StyleRule>[
  dom.css('*').styles(raw: <String, String>{'box-sizing': 'border-box'}),
  dom
      .css('html, body')
      .styles(
        raw: <String, String>{
          'margin': '0',
          'padding': '0',
          'min-height': '100%',
        },
      ),
  dom
      .css('body')
      .styles(
        raw: <String, String>{
          'font-family': '"Avenir Next", "Segoe UI", sans-serif',
          'background': 'linear-gradient(180deg, #f7f2e8 0%, #ece6dc 100%)',
          'color': '#102343',
          'line-height': '1.45',
        },
      ),
  dom.css('a').styles(raw: <String, String>{'text-decoration': 'none'}),
  dom
      .css('.shell-root')
      .styles(
        raw: <String, String>{
          'max-width': '1080px',
          'margin': '0 auto',
          'padding': '24px 16px 40px',
          'display': 'grid',
          'gap': '14px',
        },
      ),
  dom
      .css('.topbar')
      .styles(
        raw: <String, String>{
          'padding': '18px 20px',
          'border-radius': '18px',
          'background': 'linear-gradient(125deg, #0d244f 0%, #17489f 100%)',
          'box-shadow': '0 16px 44px rgba(12, 33, 75, 0.32)',
          'color': '#f6f8ff',
          'display': 'grid',
          'gap': '12px',
        },
      ),
  dom
      .css('.topbar-row')
      .styles(
        raw: <String, String>{
          'display': 'flex',
          'align-items': 'center',
          'justify-content': 'space-between',
          'flex-wrap': 'wrap',
          'gap': '10px',
        },
      ),
  dom
      .css('.brand')
      .styles(
        raw: <String, String>{
          'font-size': '1.2rem',
          'font-weight': '700',
          'letter-spacing': '0.2px',
        },
      ),
  dom
      .css('.uri-chip')
      .styles(
        raw: <String, String>{
          'padding': '5px 10px',
          'border-radius': '999px',
          'background': 'rgba(197, 219, 255, 0.22)',
          'font-size': '0.78rem',
          'letter-spacing': '0.4px',
          'color': '#d9e6ff',
          'word-break': 'break-all',
        },
      ),
  dom
      .css('.chip-row')
      .styles(
        raw: <String, String>{
          'display': 'flex',
          'align-items': 'center',
          'gap': '8px',
          'flex-wrap': 'wrap',
        },
      ),
  dom
      .css('.chip')
      .styles(
        raw: <String, String>{
          'padding': '8px 12px',
          'border-radius': '999px',
          'border': '1px solid #89a9ea',
          'background': '#f4f7ff',
          'color': '#1a3270',
          'font-size': '0.84rem',
          'font-weight': '600',
          'cursor': 'pointer',
          'display': 'inline-flex',
          'align-items': 'center',
          'justify-content': 'center',
        },
      ),
  dom
      .css('.chip-active')
      .styles(
        raw: <String, String>{
          'border-color': '#a9c4ff',
          'background': 'linear-gradient(120deg, #1f56b5, #2c74de)',
          'color': '#ffffff',
        },
      ),
  dom
      .css('.chip-ghost')
      .styles(
        raw: <String, String>{
          'border-color': '#9ab3e3',
          'background': 'transparent',
          'color': '#e6eeff',
        },
      ),
  dom
      .css('.surface')
      .styles(
        raw: <String, String>{
          'background': 'rgba(255, 255, 255, 0.8)',
          'border': '1px solid rgba(160, 181, 216, 0.45)',
          'border-radius': '18px',
          'padding': '16px',
          'box-shadow': '0 8px 28px rgba(17, 35, 67, 0.08)',
        },
      ),
  dom
      .css('.status-row')
      .styles(
        raw: <String, String>{
          'display': 'flex',
          'align-items': 'center',
          'gap': '8px',
          'flex-wrap': 'wrap',
        },
      ),
  dom
      .css('.status-pill')
      .styles(
        raw: <String, String>{
          'padding': '6px 10px',
          'border-radius': '999px',
          'font-size': '0.77rem',
          'font-weight': '600',
        },
      ),
  dom
      .css('.status-warm')
      .styles(
        raw: <String, String>{'background': '#ffe5d9', 'color': '#7f3314'},
      ),
  dom
      .css('.status-cool')
      .styles(
        raw: <String, String>{'background': '#d7f5e8', 'color': '#14583c'},
      ),
  dom
      .css('.status-note')
      .styles(
        raw: <String, String>{
          'background': '#e9edf5',
          'color': '#314a76',
          'font-weight': '500',
        },
      ),
  dom
      .css('.page')
      .styles(raw: <String, String>{'display': 'grid', 'gap': '12px'}),
  dom
      .css('.hero')
      .styles(
        raw: <String, String>{
          'padding': '16px',
          'border-radius': '16px',
          'background': 'linear-gradient(120deg, #fdf4db, #f4ecda)',
          'border': '1px solid #e4d8be',
          'display': 'grid',
          'gap': '10px',
        },
      ),
  dom
      .css('.hero-grid')
      .styles(
        raw: <String, String>{
          'display': 'grid',
          'grid-template-columns': 'repeat(auto-fit, minmax(210px, 1fr))',
          'gap': '10px',
        },
      ),
  dom
      .css('.card-grid')
      .styles(
        raw: <String, String>{
          'display': 'grid',
          'grid-template-columns': 'repeat(auto-fit, minmax(220px, 1fr))',
          'gap': '10px',
        },
      ),
  dom
      .css('.card')
      .styles(
        raw: <String, String>{
          'padding': '12px',
          'border-radius': '14px',
          'border': '1px solid #d8e0ee',
          'background': '#ffffff',
          'display': 'grid',
          'gap': '9px',
        },
      ),
  dom
      .css('.title')
      .styles(
        raw: <String, String>{
          'margin': '0',
          'font-size': '1.08rem',
          'font-weight': '700',
          'letter-spacing': '0.2px',
        },
      ),
  dom
      .css('.subtitle')
      .styles(
        raw: <String, String>{
          'margin': '0',
          'color': '#5f7092',
          'font-size': '0.92rem',
        },
      ),
  dom
      .css('.price')
      .styles(
        raw: <String, String>{
          'font-size': '1.12rem',
          'font-weight': '700',
          'color': '#15438f',
        },
      ),
  dom
      .css('.meta-list')
      .styles(
        raw: <String, String>{
          'margin': '0',
          'padding-left': '18px',
          'color': '#3d527b',
          'display': 'grid',
          'gap': '3px',
          'font-size': '0.88rem',
        },
      ),
  dom
      .css('.btn')
      .styles(
        raw: <String, String>{
          'padding': '9px 12px',
          'border-radius': '10px',
          'border': '1px solid #92aae0',
          'background': '#f4f8ff',
          'color': '#193672',
          'cursor': 'pointer',
          'font-weight': '600',
          'font-size': '0.84rem',
          'display': 'inline-flex',
          'align-items': 'center',
          'justify-content': 'center',
          'gap': '6px',
        },
      ),
  dom
      .css('.btn-primary')
      .styles(
        raw: <String, String>{
          'border-color': '#2d6cdb',
          'background': 'linear-gradient(120deg, #205abf, #2d74dd)',
          'color': '#ffffff',
        },
      ),
  dom
      .css('.btn-danger')
      .styles(
        raw: <String, String>{
          'border-color': '#cd7d68',
          'background': '#fff2ec',
          'color': '#8d3e2c',
        },
      ),
  dom
      .css('.btn-row')
      .styles(
        raw: <String, String>{
          'display': 'flex',
          'gap': '8px',
          'flex-wrap': 'wrap',
          'align-items': 'center',
        },
      ),
  dom
      .css('.line-item')
      .styles(
        raw: <String, String>{
          'display': 'grid',
          'grid-template-columns': '1fr auto',
          'gap': '8px',
          'padding': '10px',
          'border': '1px dashed #c5d2ea',
          'border-radius': '12px',
          'background': '#f8fbff',
        },
      ),
  dom
      .css('.fallback')
      .styles(
        raw: <String, String>{
          'padding': '18px',
          'border-radius': '14px',
          'background': '#fff',
          'border': '1px solid #d3ddec',
          'display': 'grid',
          'gap': '8px',
        },
      ),
  dom
      .css('.hint')
      .styles(
        raw: <String, String>{
          'font-size': '0.8rem',
          'color': '#60739a',
          'margin': '0',
        },
      ),
];

void main() {
  Jaspr.initializeApp();
  _session.reset();

  runApp(
    Document(
      title: 'Atelier Commerce - jaspr_unrouter',
      meta: const <String, String>{
        'description':
            'Complete jaspr_unrouter demo with shell, guards and data routes.',
      },
      styles: _styles,
      body: const StorefrontApp(),
    ),
  );
}

class StorefrontApp extends StatelessComponent {
  const StorefrontApp({super.key});

  @override
  Component build(BuildContext context) {
    return _router;
  }
}

Unrouter<AppRoute> createRouter({StoreSession? session}) {
  final activeSession = session ?? _session;
  return Unrouter<AppRoute>(
    routes: <RouteRecord<AppRoute>>[
      route<RootRoute>(
        path: '/',
        parse: (_) => const RootRoute(),
        redirect: (_) => const DiscoverRoute().toUri(),
        builder: (_, __) => const Component.empty(),
      ),
      route<ActionRoute>(
        path: '/action/:intent',
        parse: (state) => ActionRoute(
          intent: state.params.$enum('intent', ActionRouteIntent.values),
          next: state.query['next'],
          id: state.query.containsKey('id') ? state.query.$int('id') : null,
          qty: state.query.containsKey('qty') ? state.query.$int('qty') : null,
        ),
        redirect: (context) {
          return _resolveActionRedirect(context.route, activeSession);
        },
        builder: (_, __) => const Component.empty(),
      ),
      ...shell<AppRoute>(
        builder: (_, shellState, child) {
          return ShellChrome(shellState: shellState, child: child);
        },
        branches: <ShellBranch<AppRoute>>[
          branch<AppRoute>(
            initialLocation: const DiscoverRoute().toUri(),
            routes: <RouteRecord<AppRoute>>[
              route<DiscoverRoute>(
                path: '/discover',
                parse: (_) => const DiscoverRoute(),
                builder: (_, __) => const DiscoverPage(),
              ),
              route<CatalogRoute>(
                path: '/catalog',
                parse: (state) {
                  final tab = state.query.containsKey('tab')
                      ? state.query.$enum('tab', CatalogTab.values)
                      : CatalogTab.featured;
                  return CatalogRoute(tab: tab);
                },
                builder: (_, route) => CatalogPage(route: route),
              ),
              route<LegacyProductRoute>(
                path: '/p/:id',
                parse: (state) =>
                    LegacyProductRoute(id: state.params.$int('id')),
                redirect: (context) =>
                    ProductRoute(id: context.route.id).toUri(),
                builder: (_, route) => LegacyRedirectPage(route: route),
              ),
              dataRoute<ProductRoute, ProductDetails>(
                path: '/products/:id',
                parse: (state) {
                  final panel = state.query.containsKey('panel')
                      ? state.query.$enum('panel', ProductPanel.values)
                      : ProductPanel.overview;
                  return ProductRoute(
                    id: state.params.$int('id'),
                    panel: panel,
                  );
                },
                loader: _loadProduct,
                builder: (_, route, data) =>
                    ProductPage(route: route, data: data),
              ),
              route<QuantityRoute>(
                path: '/products/:id/quantity',
                parse: (state) => QuantityRoute(id: state.params.$int('id')),
                builder: (_, route) => QuantityPickerPage(route: route),
              ),
            ],
          ),
          branch<AppRoute>(
            initialLocation: const CartRoute().toUri(),
            routes: <RouteRecord<AppRoute>>[
              dataRoute<CartRoute, CartSummary>(
                path: '/cart',
                parse: (_) => const CartRoute(),
                loader: (context) => _loadCart(context, activeSession),
                builder: (_, route, data) => CartPage(route: route, data: data),
              ),
              dataRoute<CheckoutRoute, CheckoutSummary>(
                path: '/checkout',
                parse: (_) => const CheckoutRoute(),
                guards: <RouteGuard<CheckoutRoute>>[
                  (context) {
                    if (!activeSession.isSignedIn) {
                      return RouteGuardResult.redirect(
                        route: LoginRoute(from: context.uri.toString()),
                      );
                    }
                    if (activeSession.itemCount == 0) {
                      return const RouteGuardResult.block();
                    }
                    return const RouteGuardResult.allow();
                  },
                ],
                loader: (context) => _loadCheckout(context, activeSession),
                builder: (_, __, data) => CheckoutPage(data: data),
              ),
            ],
          ),
        ],
      ),
      route<LoginRoute>(
        path: '/login',
        parse: (state) => LoginRoute(from: state.query['from']),
        builder: (_, route) => LoginPage(route: route),
      ),
    ],
    unknown: (_, uri) {
      return FallbackPage(
        title: 'Route not found',
        detail: 'No route matches "${uri.path}".',
      );
    },
    blocked: (_, uri) {
      return FallbackPage(
        title: 'Route blocked by guard',
        detail: 'Access to "${uri.path}" is currently blocked.',
      );
    },
    loading: (_, uri) {
      return FallbackPage(
        title: 'Resolving route',
        detail: 'Loading ${uri.path} ...',
      );
    },
    onError: (_, error, __) {
      return FallbackPage(
        title: 'Route resolution error',
        detail: error.toString(),
      );
    },
    resolveInitialRoute: true,
  );
}

class ShellChrome extends StatelessComponent {
  const ShellChrome({super.key, required this.shellState, required this.child});

  final ShellState<AppRoute> shellState;
  final Component child;

  @override
  Component build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();

    return dom.div(<Component>[
      dom.header(<Component>[
        dom.div(<Component>[
          dom.div(<Component>[
            Component.text('Atelier Commerce'),
          ], classes: 'brand'),
          dom.div(<Component>[
            Component.text(controller.state.uri.toString()),
          ], classes: 'uri-chip'),
        ], classes: 'topbar-row'),
        dom.div(<Component>[
          _chipButton(
            label: 'Explore',
            active: shellState.activeBranchIndex == 0,
            route: const DiscoverRoute(),
          ),
          _chipButton(
            label: 'Wallet',
            active: shellState.activeBranchIndex == 1,
            route: const CartRoute(),
          ),
          _chipButton(
            label: _session.isSignedIn ? 'Sign out' : 'Sign in',
            ghost: true,
            route: ActionRoute(
              intent: ActionRouteIntent.toggleAuth,
              next: controller.state.uri.toString(),
            ),
          ),
        ], classes: 'chip-row'),
        dom.div(<Component>[
          _statusPill('Items ${_session.itemCount}', warm: false),
          _statusPill(
            _session.isSignedIn ? 'Signed in' : 'Guest mode',
            warm: !_session.isSignedIn,
          ),
          _statusPill(_session.status, warm: false, note: true),
        ], classes: 'status-row'),
      ], classes: 'topbar'),
      dom.main_(<Component>[child], classes: 'surface'),
      dom.p(<Component>[
        Component.text(
          'Demo coverage: shell branches, redirects, guard block, data loader, typed push/pop result.',
        ),
      ], classes: 'hint'),
    ], classes: 'shell-root');
  }
}

class DiscoverPage extends StatelessComponent {
  const DiscoverPage({super.key});

  @override
  Component build(BuildContext context) {
    final featured = _productsByTab(CatalogTab.featured);

    return dom.section(<Component>[
      dom.div(<Component>[
        dom.h2(<Component>[
          Component.text('Season Open: studio pieces'),
        ], classes: 'title'),
        dom.p(<Component>[
          Component.text(
            'A calm storefront shell that keeps navigation declarative and typed across screens.',
          ),
        ], classes: 'subtitle'),
        dom.div(<Component>[
          _button(
            label: 'Browse catalog',
            primary: true,
            route: const CatalogRoute(tab: CatalogTab.studio),
          ),
          _button(
            label: 'Open legacy /p/102',
            route: const LegacyProductRoute(id: 102),
          ),
          _button(label: 'Go to cart', route: const CartRoute()),
        ], classes: 'btn-row'),
      ], classes: 'hero'),
      dom.div(<Component>[
        for (final product in featured)
          dom.article(<Component>[
            dom.h3(<Component>[
              Component.text(product.title),
            ], classes: 'title'),
            dom.p(<Component>[
              Component.text(product.subtitle),
            ], classes: 'subtitle'),
            dom.div(<Component>[
              Component.text(_usd(product.priceCents)),
            ], classes: 'price'),
            UnrouterLink<AppRoute>(
              route: ProductRoute(id: product.id),
              classes: 'btn btn-primary',
              children: <Component>[Component.text('Open product')],
            ),
          ], classes: 'card'),
      ], classes: 'card-grid'),
    ], classes: 'page');
  }
}

class CatalogPage extends StatelessComponent {
  const CatalogPage({super.key, required this.route});

  final CatalogRoute route;

  @override
  Component build(BuildContext context) {
    final products = _productsByTab(route.tab);

    return dom.section(<Component>[
      dom.div(<Component>[
        dom.h2(<Component>[
          Component.text('Catalog · ${_catalogTabLabel(route.tab)}'),
        ], classes: 'title'),
        dom.p(<Component>[
          Component.text(
            'Query-driven tab parsing keeps URLs explicit and portable.',
          ),
        ], classes: 'subtitle'),
        dom.div(<Component>[
          for (final tab in CatalogTab.values)
            _chipButton(
              label: _catalogTabLabel(tab),
              active: route.tab == tab,
              route: CatalogRoute(tab: tab),
            ),
        ], classes: 'chip-row'),
      ], classes: 'hero'),
      dom.div(<Component>[
        for (final product in products)
          dom.article(<Component>[
            dom.h3(<Component>[
              Component.text(product.title),
            ], classes: 'title'),
            dom.p(<Component>[
              Component.text(product.summary),
            ], classes: 'subtitle'),
            dom.div(<Component>[
              Component.text(_usd(product.priceCents)),
            ], classes: 'price'),
            dom.div(<Component>[
              UnrouterLink<AppRoute>(
                route: ProductRoute(id: product.id),
                classes: 'btn btn-primary',
                children: <Component>[Component.text('Details')],
              ),
              UnrouterLink<AppRoute>(
                route: ProductRoute(id: product.id, panel: ProductPanel.specs),
                classes: 'btn',
                children: <Component>[Component.text('Specs')],
              ),
            ], classes: 'btn-row'),
          ], classes: 'card'),
      ], classes: 'card-grid'),
    ], classes: 'page');
  }
}

class LegacyRedirectPage extends StatelessComponent {
  const LegacyRedirectPage({super.key, required this.route});

  final LegacyProductRoute route;

  @override
  Component build(BuildContext context) {
    return FallbackPage(
      title: 'Legacy route',
      detail: 'Redirecting from /p/${route.id} ...',
    );
  }
}

class ProductPage extends StatelessComponent {
  const ProductPage({super.key, required this.route, required this.data});

  final ProductRoute route;
  final ProductDetails data;

  @override
  Component build(BuildContext context) {
    return dom.section(<Component>[
      dom.div(<Component>[
        dom.h2(<Component>[Component.text(data.title)], classes: 'title'),
        dom.p(<Component>[Component.text(data.subtitle)], classes: 'subtitle'),
        dom.div(<Component>[
          Component.text(_usd(data.priceCents)),
        ], classes: 'price'),
        dom.div(<Component>[
          for (final panel in ProductPanel.values)
            _chipButton(
              label: _panelLabel(panel),
              active: route.panel == panel,
              route: ProductRoute(id: route.id, panel: panel),
            ),
        ], classes: 'chip-row'),
      ], classes: 'hero'),
      dom.div(<Component>[
        dom.article(<Component>[
          dom.h3(<Component>[
            Component.text('Panel · ${_panelLabel(route.panel)}'),
          ], classes: 'title'),
          dom.p(<Component>[
            Component.text(_panelCopy(route.panel, data)),
          ], classes: 'subtitle'),
          dom.ul(<Component>[
            for (final item in data.materials)
              dom.li(<Component>[Component.text(item)]),
          ], classes: 'meta-list'),
        ], classes: 'card'),
        dom.article(<Component>[
          dom.h3(<Component>[Component.text('Actions')], classes: 'title'),
          dom.p(<Component>[
            Component.text(
              'Use push/pop result to choose quantity and complete cart updates.',
            ),
          ], classes: 'subtitle'),
          dom.div(<Component>[
            _button(
              label: 'Add one',
              primary: true,
              route: ActionRoute(
                intent: ActionRouteIntent.addItem,
                id: route.id,
                qty: 1,
                next: const CartRoute().toUri().toString(),
              ),
            ),
            _button(
              label: 'Choose quantity',
              route: QuantityRoute(id: route.id),
            ),
            _button(
              label: 'Back to catalog',
              route: const CatalogRoute(tab: CatalogTab.featured),
            ),
          ], classes: 'btn-row'),
        ], classes: 'card'),
      ], classes: 'hero-grid'),
    ], classes: 'page');
  }
}

class QuantityPickerPage extends StatelessComponent {
  const QuantityPickerPage({super.key, required this.route});

  final QuantityRoute route;

  @override
  Component build(BuildContext context) {
    final title = _catalog[route.id]?.title ?? 'Product ${route.id}';

    return dom.section(<Component>[
      dom.article(<Component>[
        dom.h2(<Component>[
          Component.text('Choose quantity'),
        ], classes: 'title'),
        dom.p(<Component>[Component.text(title)], classes: 'subtitle'),
        dom.div(<Component>[
          for (var amount = 1; amount <= 4; amount++)
            _button(
              label: '$amount ×',
              primary: amount == 2,
              route: ActionRoute(
                intent: ActionRouteIntent.addItem,
                id: route.id,
                qty: amount,
                next: const CartRoute().toUri().toString(),
              ),
            ),
          _button(
            label: 'Cancel',
            danger: true,
            route: ProductRoute(id: route.id),
          ),
        ], classes: 'btn-row'),
      ], classes: 'card'),
    ], classes: 'page');
  }
}

class CartPage extends StatelessComponent {
  const CartPage({super.key, required this.route, required this.data});

  final CartRoute route;
  final CartSummary data;

  @override
  Component build(BuildContext context) {
    route;

    return dom.section(<Component>[
      dom.div(<Component>[
        dom.h2(<Component>[Component.text('Cart')], classes: 'title'),
        dom.p(<Component>[
          Component.text('Loader-backed summary for current line items.'),
        ], classes: 'subtitle'),
        dom.div(<Component>[
          _statusPill('Lines ${data.lines.length}', warm: false),
          _statusPill('Items ${data.itemCount}', warm: false),
          _statusPill('Total ${_usd(data.totalCents)}', warm: false),
        ], classes: 'status-row'),
      ], classes: 'hero'),
      if (data.lines.isEmpty)
        dom.article(<Component>[
          dom.h3(<Component>[
            Component.text('Cart is empty'),
          ], classes: 'title'),
          dom.p(<Component>[
            Component.text(
              'Add something from Explore to test checkout guards.',
            ),
          ], classes: 'subtitle'),
          _button(
            label: 'Browse catalog',
            primary: true,
            route: const CatalogRoute(tab: CatalogTab.featured),
          ),
        ], classes: 'card')
      else
        dom.div(<Component>[
          for (final line in data.lines)
            dom.div(<Component>[
              dom.div(<Component>[
                dom.p(<Component>[
                  Component.text(line.title),
                ], classes: 'title'),
                dom.p(<Component>[
                  Component.text(
                    '${line.quantity} × ${_usd(line.unitPriceCents)}',
                  ),
                ], classes: 'subtitle'),
              ]),
              dom.div(<Component>[
                Component.text(_usd(line.totalCents)),
              ], classes: 'price'),
            ], classes: 'line-item'),
        ], classes: 'page'),
      dom.div(<Component>[
        _button(label: 'Checkout', primary: true, route: const CheckoutRoute()),
        _button(
          label: 'Clear cart',
          danger: true,
          route: ActionRoute(
            intent: ActionRouteIntent.clearCart,
            next: const CartRoute().toUri().toString(),
          ),
        ),
      ], classes: 'btn-row'),
    ], classes: 'page');
  }
}

class CheckoutPage extends StatelessComponent {
  const CheckoutPage({super.key, required this.data});

  final CheckoutSummary data;

  @override
  Component build(BuildContext context) {
    return dom.section(<Component>[
      dom.article(<Component>[
        dom.h2(<Component>[Component.text('Checkout')], classes: 'title'),
        dom.p(<Component>[
          Component.text(
            'Guard requires both authentication and a non-empty cart.',
          ),
        ], classes: 'subtitle'),
        dom.div(<Component>[
          _statusPill('${data.itemCount} items', warm: false),
          _statusPill('ETA ${data.etaMinutes} min', warm: false),
          _statusPill(_usd(data.totalCents), warm: false),
        ], classes: 'status-row'),
        dom.div(<Component>[
          _button(
            label: 'Pay now',
            primary: true,
            route: ActionRoute(
              intent: ActionRouteIntent.pay,
              next: const DiscoverRoute().toUri().toString(),
            ),
          ),
          _button(label: 'Back to cart', route: const CartRoute()),
        ], classes: 'btn-row'),
      ], classes: 'card'),
    ], classes: 'page');
  }
}

class LoginPage extends StatelessComponent {
  const LoginPage({super.key, required this.route});

  final LoginRoute route;

  @override
  Component build(BuildContext context) {
    final target = route.from == null
        ? const CartRoute().toUri()
        : (Uri.tryParse(route.from!) ?? const CartRoute().toUri());

    return dom.section(<Component>[
      dom.article(<Component>[
        dom.h2(<Component>[
          Component.text('Sign in required'),
        ], classes: 'title'),
        dom.p(<Component>[
          Component.text(
            'Checkout is protected. Sign in to continue your route.',
          ),
        ], classes: 'subtitle'),
        dom.p(<Component>[
          Component.text('Return target: ${target.toString()}'),
        ], classes: 'hint'),
        dom.div(<Component>[
          _button(
            label: 'Sign in and continue',
            primary: true,
            route: ActionRoute(
              intent: ActionRouteIntent.signIn,
              next: target.toString(),
            ),
          ),
          _button(label: 'Back to explore', route: const DiscoverRoute()),
        ], classes: 'btn-row'),
      ], classes: 'card'),
    ], classes: 'page');
  }
}

class FallbackPage extends StatelessComponent {
  const FallbackPage({super.key, required this.title, required this.detail});

  final String title;
  final String detail;

  @override
  Component build(BuildContext context) {
    return dom.section(<Component>[
      dom.article(<Component>[
        dom.h2(<Component>[Component.text(title)], classes: 'title'),
        dom.p(<Component>[Component.text(detail)], classes: 'subtitle'),
        _button(
          label: 'Go to discover',
          primary: true,
          route: const DiscoverRoute(),
        ),
      ], classes: 'fallback'),
    ], classes: 'page');
  }
}

Component _statusPill(String label, {required bool warm, bool note = false}) {
  final classes = StringBuffer('status-pill');
  if (note) {
    classes.write(' status-note');
  } else if (warm) {
    classes.write(' status-warm');
  } else {
    classes.write(' status-cool');
  }

  return dom.span(<Component>[
    Component.text(label),
  ], classes: classes.toString());
}

Component _chipButton({
  required String label,
  required AppRoute route,
  bool active = false,
  bool ghost = false,
}) {
  final classes = StringBuffer('chip');
  if (active) {
    classes.write(' chip-active');
  }
  if (ghost) {
    classes.write(' chip-ghost');
  }

  return UnrouterLink<AppRoute>(
    route: route,
    classes: classes.toString(),
    children: <Component>[Component.text(label)],
  );
}

Component _button({
  required String label,
  required AppRoute route,
  bool primary = false,
  bool danger = false,
}) {
  final classes = StringBuffer('btn');
  if (primary) {
    classes.write(' btn-primary');
  }
  if (danger) {
    classes.write(' btn-danger');
  }

  return UnrouterLink<AppRoute>(
    route: route,
    classes: classes.toString(),
    children: <Component>[Component.text(label)],
  );
}

Future<ProductDetails> _loadProduct(RouteContext<ProductRoute> context) async {
  await Future<void>.delayed(const Duration(milliseconds: 120));

  final product = _catalog[context.route.id];
  if (product == null) {
    throw StateError('Product ${context.route.id} does not exist.');
  }
  return product;
}

Future<CartSummary> _loadCart(
  RouteContext<CartRoute> context,
  StoreSession session,
) async {
  context.signal.throwIfCancelled();
  await Future<void>.delayed(const Duration(milliseconds: 90));
  context.signal.throwIfCancelled();

  final lines = <CartLine>[];
  var totalCents = 0;
  var totalItems = 0;

  for (final entry in session.cartEntries) {
    final product = _catalog[entry.key];
    if (product == null) {
      continue;
    }
    final quantity = entry.value;
    final lineTotal = product.priceCents * quantity;
    lines.add(
      CartLine(
        productId: product.id,
        title: product.title,
        quantity: quantity,
        unitPriceCents: product.priceCents,
        totalCents: lineTotal,
      ),
    );
    totalItems += quantity;
    totalCents += lineTotal;
  }

  return CartSummary(
    lines: lines,
    itemCount: totalItems,
    totalCents: totalCents,
  );
}

Future<CheckoutSummary> _loadCheckout(
  RouteContext<CheckoutRoute> context,
  StoreSession session,
) async {
  context.signal.throwIfCancelled();
  await Future<void>.delayed(const Duration(milliseconds: 110));
  context.signal.throwIfCancelled();

  var totalCents = 0;
  var itemCount = 0;
  for (final entry in session.cartEntries) {
    final product = _catalog[entry.key];
    if (product == null) {
      continue;
    }
    itemCount += entry.value;
    totalCents += product.priceCents * entry.value;
  }

  return CheckoutSummary(
    itemCount: itemCount,
    totalCents: totalCents,
    etaMinutes: 12 + itemCount * 2,
  );
}

List<ProductDetails> _productsByTab(CatalogTab tab) {
  return _catalog.values
      .where((product) => product.tabs.contains(tab))
      .toList();
}

String _usd(int cents) {
  final dollars = cents ~/ 100;
  final remains = (cents % 100).toString().padLeft(2, '0');
  return '\$$dollars.$remains';
}

String _catalogTabLabel(CatalogTab tab) {
  return switch (tab) {
    CatalogTab.featured => 'Featured',
    CatalogTab.studio => 'Studio',
    CatalogTab.essentials => 'Essentials',
  };
}

String _panelLabel(ProductPanel panel) {
  return switch (panel) {
    ProductPanel.overview => 'Overview',
    ProductPanel.specs => 'Specs',
    ProductPanel.reviews => 'Reviews',
  };
}

String _panelCopy(ProductPanel panel, ProductDetails product) {
  return switch (panel) {
    ProductPanel.overview => product.summary,
    ProductPanel.specs =>
      'Craft profile tuned for durability and daily ergonomics.',
    ProductPanel.reviews =>
      'Loved by operators who prefer tactile feedback with calm acoustics.',
  };
}

Uri _resolveActionRedirect(ActionRoute route, StoreSession session) {
  final defaultNext = const DiscoverRoute().toUri();
  final next = _normalizeLocalUri(route.next, fallback: defaultNext);

  switch (route.intent) {
    case ActionRouteIntent.toggleAuth:
      session.toggleAuth();
      return next;
    case ActionRouteIntent.addItem:
      final id = route.id;
      final qty = route.qty;
      if (id != null && qty != null && qty > 0) {
        session.addItem(id, qty: qty);
      }
      return next;
    case ActionRouteIntent.clearCart:
      session.clearCart(reason: 'Cart cleared from wallet view');
      return next;
    case ActionRouteIntent.pay:
      session.clearCart(reason: 'Order confirmed. Payment captured.');
      return next;
    case ActionRouteIntent.signIn:
      session.signIn();
      if (next.path == '/checkout' && session.itemCount == 0) {
        session.note('Checkout requires cart items first');
        return const CartRoute().toUri();
      }
      return next;
  }
}

Uri _normalizeLocalUri(String? raw, {required Uri fallback}) {
  if (raw == null) {
    return fallback;
  }
  final parsed = Uri.tryParse(raw);
  if (parsed == null || parsed.hasScheme || parsed.hasAuthority) {
    return fallback;
  }
  if (parsed.path.isEmpty) {
    return Uri(
      path: fallback.path,
      query: parsed.hasQuery ? parsed.query : fallback.query,
      fragment: parsed.hasFragment ? parsed.fragment : fallback.fragment,
    );
  }
  return parsed;
}

sealed class AppRoute implements RouteData {
  const AppRoute();
}

final class RootRoute extends AppRoute {
  const RootRoute();

  @override
  Uri toUri() => Uri(path: '/');
}

final class DiscoverRoute extends AppRoute {
  const DiscoverRoute();

  @override
  Uri toUri() => Uri(path: '/discover');
}

final class CatalogRoute extends AppRoute {
  const CatalogRoute({required this.tab});

  final CatalogTab tab;

  @override
  Uri toUri() {
    final query = tab == CatalogTab.featured
        ? null
        : <String, String>{'tab': tab.name};
    return Uri(path: '/catalog', queryParameters: query);
  }
}

final class LegacyProductRoute extends AppRoute {
  const LegacyProductRoute({required this.id});

  final int id;

  @override
  Uri toUri() => Uri(path: '/p/$id');
}

final class ProductRoute extends AppRoute {
  const ProductRoute({required this.id, this.panel = ProductPanel.overview});

  final int id;
  final ProductPanel panel;

  @override
  Uri toUri() {
    final query = panel == ProductPanel.overview
        ? null
        : <String, String>{'panel': panel.name};
    return Uri(path: '/products/$id', queryParameters: query);
  }
}

final class QuantityRoute extends AppRoute {
  const QuantityRoute({required this.id});

  final int id;

  @override
  Uri toUri() => Uri(path: '/products/$id/quantity');
}

final class CartRoute extends AppRoute {
  const CartRoute();

  @override
  Uri toUri() => Uri(path: '/cart');
}

final class CheckoutRoute extends AppRoute {
  const CheckoutRoute();

  @override
  Uri toUri() => Uri(path: '/checkout');
}

final class LoginRoute extends AppRoute {
  const LoginRoute({this.from});

  final String? from;

  @override
  Uri toUri() {
    final query = from == null ? null : <String, String>{'from': from!};
    return Uri(path: '/login', queryParameters: query);
  }
}

final class ActionRoute extends AppRoute {
  const ActionRoute({required this.intent, this.next, this.id, this.qty});

  final ActionRouteIntent intent;
  final String? next;
  final int? id;
  final int? qty;

  @override
  Uri toUri() {
    final query = <String, String>{
      if (next != null) 'next': next!,
      if (id != null) 'id': '$id',
      if (qty != null) 'qty': '$qty',
    };
    return Uri(
      path: '/action/${intent.name}',
      queryParameters: query.isEmpty ? null : query,
    );
  }
}

enum ActionRouteIntent { toggleAuth, addItem, clearCart, pay, signIn }

enum CatalogTab { featured, studio, essentials }

enum ProductPanel { overview, specs, reviews }

class ProductDetails {
  const ProductDetails({
    required this.id,
    required this.title,
    required this.subtitle,
    required this.summary,
    required this.priceCents,
    required this.materials,
    required this.tabs,
  });

  final int id;
  final String title;
  final String subtitle;
  final String summary;
  final int priceCents;
  final List<String> materials;
  final Set<CatalogTab> tabs;
}

class CartLine {
  const CartLine({
    required this.productId,
    required this.title,
    required this.quantity,
    required this.unitPriceCents,
    required this.totalCents,
  });

  final int productId;
  final String title;
  final int quantity;
  final int unitPriceCents;
  final int totalCents;
}

class CartSummary {
  const CartSummary({
    required this.lines,
    required this.itemCount,
    required this.totalCents,
  });

  final List<CartLine> lines;
  final int itemCount;
  final int totalCents;
}

class CheckoutSummary {
  const CheckoutSummary({
    required this.itemCount,
    required this.totalCents,
    required this.etaMinutes,
  });

  final int itemCount;
  final int totalCents;
  final int etaMinutes;
}

class StoreSession extends ChangeNotifier {
  bool _signedIn = false;
  final Map<int, int> _cart = <int, int>{};
  String _status = 'Ready';

  bool get isSignedIn => _signedIn;
  int get itemCount => _cart.values.fold(0, (sum, qty) => sum + qty);
  String get status => _status;
  Iterable<MapEntry<int, int>> get cartEntries => _cart.entries;

  void reset() {
    _signedIn = false;
    _cart.clear();
    _status = 'Ready';
  }

  void toggleAuth() {
    _signedIn = !_signedIn;
    _status = _signedIn ? 'Signed in for checkout' : 'Signed out to guest mode';
    notifyListeners();
  }

  void signIn() {
    if (_signedIn) {
      _status = 'Already signed in';
      notifyListeners();
      return;
    }
    _signedIn = true;
    _status = 'Sign-in complete';
    notifyListeners();
  }

  void addItem(int productId, {required int qty}) {
    if (qty <= 0) {
      return;
    }

    _cart[productId] = (_cart[productId] ?? 0) + qty;
    final name = _catalog[productId]?.title ?? 'product $productId';
    _status = 'Added $qty × $name';
    notifyListeners();
  }

  void clearCart({required String reason}) {
    _cart.clear();
    _status = reason;
    notifyListeners();
  }

  void note(String message) {
    _status = message;
    notifyListeners();
  }
}

const Map<int, ProductDetails> _catalog = <int, ProductDetails>{
  101: ProductDetails(
    id: 101,
    title: 'Aster 65 Keyboard',
    subtitle: 'Compact aluminum frame, warm tactility',
    summary:
        'A compact board tuned for low-noise tactile response and dense desk setups.',
    priceCents: 15900,
    materials: <String>[
      'CNC aluminum chassis',
      'Poron dampening',
      'PBT keycaps',
    ],
    tabs: <CatalogTab>{CatalogTab.featured, CatalogTab.studio},
  ),
  102: ProductDetails(
    id: 102,
    title: 'Monolith Desk Mat',
    subtitle: 'Textile weave with anti-slip underside',
    summary:
        'Wide desk coverage with muted texture designed for sustained writing sessions.',
    priceCents: 4900,
    materials: <String>[
      'Micro-knit textile',
      'Natural rubber base',
      'Stitched edge',
    ],
    tabs: <CatalogTab>{CatalogTab.featured, CatalogTab.essentials},
  ),
  103: ProductDetails(
    id: 103,
    title: 'Lumen Task Lamp',
    subtitle: 'Dimmable light profile for focus zones',
    summary:
        'Directional lighting with soft diffusion, optimized for long evening workflows.',
    priceCents: 12900,
    materials: <String>[
      'Anodized armature',
      'CRI 95 LED module',
      'Touch dimmer',
    ],
    tabs: <CatalogTab>{CatalogTab.studio, CatalogTab.essentials},
  ),
  104: ProductDetails(
    id: 104,
    title: 'Arc Cable Set',
    subtitle: 'Braided USB-C pair in studio palette',
    summary:
        'Color-matched cable duo to keep routing clean across keyboard and tablet.',
    priceCents: 2900,
    materials: <String>[
      'Braided sleeve',
      'Aluminum connector shell',
      'Detachable coupler',
    ],
    tabs: <CatalogTab>{CatalogTab.essentials, CatalogTab.featured},
  ),
};
2
likes
140
points
15
downloads

Documentation

API reference

Publisher

verified publishermedz.dev

Weekly Downloads

Jaspr adapter for the unrouter typed routing core.

Topics

#jaspr #router #navigation #routing #typed

License

MIT (license)

Dependencies

jaspr, unrouter, unstory

More

Packages that depend on jaspr_unrouter