create-nymbal-app 0.0.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-nymbal-app.mjs +9 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +419 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -7
- package/scaffold/project/astro/src/pages/index.astro +57 -0
- package/scaffold/templates/astro/README.md +26 -0
- package/scaffold/templates/astro/_dot.env.example +6 -0
- package/scaffold/templates/astro/_dot.gitignore +16 -0
- package/scaffold/templates/astro/_dot.node-version +1 -0
- package/scaffold/templates/astro/_dot.npmrc +1 -0
- package/scaffold/templates/astro/astro.config.mjs +14 -0
- package/scaffold/templates/astro/package.json +59 -0
- package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
- package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
- package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
- package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
- package/scaffold/templates/astro/src/components/Footer.astro +108 -0
- package/scaffold/templates/astro/src/components/Header.astro +192 -0
- package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
- package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
- package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
- package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
- package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
- package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
- package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
- package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
- package/scaffold/templates/astro/src/components/ReviewForm.astro +74 -0
- package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
- package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
- package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
- package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
- package/scaffold/templates/astro/src/env.d.ts +2 -0
- package/scaffold/templates/astro/src/layouts/AccountLayout.astro +170 -0
- package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
- package/scaffold/templates/astro/src/lib/api.ts +89 -0
- package/scaffold/templates/astro/src/lib/client.ts +13 -0
- package/scaffold/templates/astro/src/lib/format.ts +17 -0
- package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
- package/scaffold/templates/astro/src/lib/seo.ts +62 -0
- package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
- package/scaffold/templates/astro/src/pages/404.astro +18 -0
- package/scaffold/templates/astro/src/pages/500.astro +17 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
- package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
- package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
- package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
- package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
- package/scaffold/templates/astro/src/pages/account/profile.astro +69 -0
- package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
- package/scaffold/templates/astro/src/pages/cart.astro +16 -0
- package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
- package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
- package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
- package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
- package/scaffold/templates/astro/src/pages/index.astro +49 -0
- package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
- package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
- package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
- package/scaffold/templates/astro/src/pages/search.astro +37 -0
- package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +82 -0
- package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
- package/scaffold/templates/astro/src/scripts/checkout.ts +272 -0
- package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
- package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
- package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
- package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
- package/scaffold/templates/astro/src/scripts/profile-form.ts +57 -0
- package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
- package/scaffold/templates/astro/src/scripts/search.ts +145 -0
- package/scaffold/templates/astro/src/styles/global.css +362 -0
- package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
- package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
- package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
- package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
- package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
- package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
- package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
- package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
- package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
- package/scaffold/templates/astro/tsconfig.json +3 -0
- package/scaffold/templates/nextjs/README.md +26 -0
- package/scaffold/templates/nextjs/_dot.env.example +6 -0
- package/scaffold/templates/nextjs/_dot.gitignore +17 -0
- package/scaffold/templates/nextjs/_dot.node-version +1 -0
- package/scaffold/templates/nextjs/_dot.npmrc +1 -0
- package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
- package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
- package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
- package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
- package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
- package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
- package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/error.tsx +49 -0
- package/scaffold/templates/nextjs/app/globals.css +473 -0
- package/scaffold/templates/nextjs/app/layout.tsx +29 -0
- package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
- package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
- package/scaffold/templates/nextjs/app/page.tsx +63 -0
- package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
- package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
- package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
- package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
- package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
- package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
- package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
- package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
- package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
- package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
- package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
- package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
- package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
- package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
- package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
- package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
- package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
- package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
- package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
- package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
- package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
- package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
- package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
- package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
- package/scaffold/templates/nextjs/lib/api.ts +89 -0
- package/scaffold/templates/nextjs/lib/client.ts +23 -0
- package/scaffold/templates/nextjs/lib/format.ts +7 -0
- package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
- package/scaffold/templates/nextjs/lib/seo.ts +73 -0
- package/scaffold/templates/nextjs/next.config.mjs +9 -0
- package/scaffold/templates/nextjs/package.json +64 -0
- package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
- package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
- package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
- package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
- package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
- package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
- package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
- package/scaffold/templates/nextjs/tsconfig.json +41 -0
- package/README.md +0 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function formatPrice(minor: number, currency: string): string {
|
|
2
|
+
return new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(minor / 100)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatDate(iso: string): string {
|
|
6
|
+
return new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }).format(new Date(iso))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Escapes text for interpolation into an HTML string; account pages render API data with innerHTML. */
|
|
10
|
+
export function escapeHtml(value: unknown): string {
|
|
11
|
+
return String(value ?? '')
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''')
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OrderStatus } from '@nymbalhq/types'
|
|
2
|
+
|
|
3
|
+
const PAID: ReadonlySet<OrderStatus> = new Set(['confirmed', 'processing', 'shipped', 'delivered'])
|
|
4
|
+
|
|
5
|
+
/** The order page's heading, from the order's real state: never "confirmed" before the payment is. */
|
|
6
|
+
export function orderHeading(status: OrderStatus): string {
|
|
7
|
+
if (PAID.has(status)) return 'Order confirmed'
|
|
8
|
+
if (status === 'pending') return 'Awaiting payment'
|
|
9
|
+
return `Order ${status.replace(/_/g, ' ')}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isPaid(status: OrderStatus): boolean {
|
|
13
|
+
return PAID.has(status)
|
|
14
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
2
|
+
|
|
3
|
+
export function productJsonLd(product: DenormalisedProduct, baseUrl: string) {
|
|
4
|
+
// The SDK types `media` as unknown: the projection stores either URLs or `{ url }` objects.
|
|
5
|
+
const media: unknown[] = Array.isArray(product.media) ? product.media : []
|
|
6
|
+
const images = media
|
|
7
|
+
.map((m) =>
|
|
8
|
+
typeof m === 'string'
|
|
9
|
+
? m
|
|
10
|
+
: m && typeof m === 'object' && 'url' in m && typeof m.url === 'string'
|
|
11
|
+
? m.url
|
|
12
|
+
: null,
|
|
13
|
+
)
|
|
14
|
+
.filter((url): url is string => url !== null)
|
|
15
|
+
|
|
16
|
+
const sku = product.variants?.[0]?.sku ?? product.id
|
|
17
|
+
|
|
18
|
+
const lowPrice = (product.priceRange?.minMinor ?? product.priceMinor ?? 0) / 100
|
|
19
|
+
const highPrice = (product.priceRange?.maxMinor ?? product.priceMinor ?? 0) / 100
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
'@context': 'https://schema.org',
|
|
23
|
+
'@type': 'Product',
|
|
24
|
+
name: product.name,
|
|
25
|
+
description: product.description,
|
|
26
|
+
image: images,
|
|
27
|
+
sku,
|
|
28
|
+
url: `${baseUrl}/product/${product.slug}`,
|
|
29
|
+
offers: {
|
|
30
|
+
'@type': 'AggregateOffer',
|
|
31
|
+
lowPrice: lowPrice.toFixed(2),
|
|
32
|
+
highPrice: highPrice.toFixed(2),
|
|
33
|
+
priceCurrency: product.currency,
|
|
34
|
+
availability: product.inStock
|
|
35
|
+
? 'https://schema.org/InStock'
|
|
36
|
+
: 'https://schema.org/OutOfStock',
|
|
37
|
+
offerCount: product.variants?.length ?? 1,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function breadcrumbJsonLd(items: Array<{ name: string; url: string }>) {
|
|
43
|
+
return {
|
|
44
|
+
'@context': 'https://schema.org',
|
|
45
|
+
'@type': 'BreadcrumbList',
|
|
46
|
+
itemListElement: items.map((item, index) => ({
|
|
47
|
+
'@type': 'ListItem',
|
|
48
|
+
position: index + 1,
|
|
49
|
+
name: item.name,
|
|
50
|
+
item: item.url,
|
|
51
|
+
})),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function organizationJsonLd(storeName: string, url: string) {
|
|
56
|
+
return {
|
|
57
|
+
'@context': 'https://schema.org',
|
|
58
|
+
'@type': 'Organization',
|
|
59
|
+
name: storeName,
|
|
60
|
+
url,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** What a handler resolves when it has sent the browser elsewhere (`location.href = …`): the form stays locked. */
|
|
2
|
+
export type SubmitOutcome = void | 'leaving'
|
|
3
|
+
|
|
4
|
+
export type SubmitHandler = (event: SubmitEvent) => Promise<SubmitOutcome> | SubmitOutcome
|
|
5
|
+
|
|
6
|
+
function submitButtonOf(event: SubmitEvent, form: HTMLFormElement): HTMLButtonElement | null {
|
|
7
|
+
if (event.submitter instanceof HTMLButtonElement) return event.submitter
|
|
8
|
+
return form.querySelector<HTMLButtonElement>('button[type="submit"], button:not([type])')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Runs `handler` for a submit of `form`, ignoring every further submit until it settles. The flag, not the button, is
|
|
13
|
+
* the guard: `requestSubmit()` and Enter in a field still fire `submit` while the button is disabled.
|
|
14
|
+
*/
|
|
15
|
+
export function submitOnce(form: HTMLFormElement | null, handler: SubmitHandler): void {
|
|
16
|
+
if (form === null) return
|
|
17
|
+
let inFlight = false
|
|
18
|
+
|
|
19
|
+
form.addEventListener('submit', (event) => {
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
if (inFlight) return
|
|
22
|
+
inFlight = true
|
|
23
|
+
const button = submitButtonOf(event as SubmitEvent, form)
|
|
24
|
+
if (button) button.disabled = true
|
|
25
|
+
|
|
26
|
+
const release = () => {
|
|
27
|
+
inFlight = false
|
|
28
|
+
if (button) button.disabled = false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Inside `then`, a synchronous throw from the handler becomes a rejection and releases the form like one.
|
|
32
|
+
Promise.resolve()
|
|
33
|
+
.then(() => handler(event as SubmitEvent))
|
|
34
|
+
.then(
|
|
35
|
+
(outcome) => {
|
|
36
|
+
if (outcome !== 'leaving') release()
|
|
37
|
+
},
|
|
38
|
+
(reason: unknown) => {
|
|
39
|
+
release()
|
|
40
|
+
console.error('A form submit handler failed', reason)
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/pages/error.css'
|
|
3
|
+
import BaseLayout from '../layouts/BaseLayout.astro'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<BaseLayout title="Page Not Found | Nymbal" description="The page you're looking for doesn't exist.">
|
|
7
|
+
<div class="error-page">
|
|
8
|
+
<span class="error-code">404</span>
|
|
9
|
+
<h1 class="error-title">Page not found</h1>
|
|
10
|
+
<p class="error-message">
|
|
11
|
+
The page you're looking for doesn't exist or may have been moved.
|
|
12
|
+
</p>
|
|
13
|
+
<div class="error-actions">
|
|
14
|
+
<a href="/" class="btn btn-primary">Go to Homepage</a>
|
|
15
|
+
<a href="/products" class="btn btn-secondary">Browse Products</a>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</BaseLayout>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/pages/error.css'
|
|
3
|
+
import BaseLayout from '../layouts/BaseLayout.astro'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<BaseLayout title="Server Error | Nymbal" description="Something went wrong on our end.">
|
|
7
|
+
<div class="error-page server-error">
|
|
8
|
+
<span class="error-code">500</span>
|
|
9
|
+
<h1 class="error-title">Something went wrong</h1>
|
|
10
|
+
<p class="error-message">
|
|
11
|
+
We're having trouble loading this page. Please try again in a moment.
|
|
12
|
+
</p>
|
|
13
|
+
<div class="error-actions">
|
|
14
|
+
<a href="/" class="btn btn-primary">Go to Homepage</a>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</BaseLayout>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/account.css'
|
|
3
|
+
import '../../styles/pages/checkout.css'
|
|
4
|
+
import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
5
|
+
import AddressForm from '../../components/AddressForm.astro'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<AccountLayout title="Addresses">
|
|
9
|
+
<h1 class="account-page-title">Addresses</h1>
|
|
10
|
+
|
|
11
|
+
<div id="addresses-container" data-testid="addresses-list">
|
|
12
|
+
<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: var(--nymbal-font-size-sm, 0.875rem);">
|
|
13
|
+
Loading addresses...
|
|
14
|
+
</p>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div style="margin-top: var(--nymbal-spacing-xl, 2rem);">
|
|
18
|
+
<h2 class="section-title">Add New Address</h2>
|
|
19
|
+
<form id="add-address-form" class="account-form" data-testid="add-address-form">
|
|
20
|
+
<AddressForm id="new-address" />
|
|
21
|
+
<div class="account-form-actions">
|
|
22
|
+
<button type="submit" class="btn btn-primary" data-testid="save-address">Save Address</button>
|
|
23
|
+
</div>
|
|
24
|
+
</form>
|
|
25
|
+
<p class="form-message" id="address-form-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
|
|
26
|
+
</div>
|
|
27
|
+
</AccountLayout>
|
|
28
|
+
|
|
29
|
+
<script>
|
|
30
|
+
import type { Address, CustomerAddress } from '@nymbalhq/types'
|
|
31
|
+
import { client } from '../../lib/client.js'
|
|
32
|
+
import { escapeHtml } from '../../lib/format.js'
|
|
33
|
+
import { submitOnce } from '../../lib/submit-once.js'
|
|
34
|
+
|
|
35
|
+
const container = document.getElementById('addresses-container')
|
|
36
|
+
const form = document.getElementById('add-address-form') as HTMLFormElement | null
|
|
37
|
+
const messageEl = document.getElementById('address-form-message')
|
|
38
|
+
|
|
39
|
+
function renderAddress(addr: CustomerAddress): string {
|
|
40
|
+
const isDefault = addr.isDefaultShipping || addr.isDefaultBilling
|
|
41
|
+
return `
|
|
42
|
+
<div class="address-card ${isDefault ? 'default' : ''}" data-testid="address-${escapeHtml(addr.id)}">
|
|
43
|
+
${isDefault ? '<span class="address-card-badge">Default</span>' : ''}
|
|
44
|
+
<div class="address-card-line">${escapeHtml(`${addr.firstName} ${addr.lastName}`)}</div>
|
|
45
|
+
<div class="address-card-line">${escapeHtml(addr.addressLine1)}</div>
|
|
46
|
+
${addr.addressLine2 ? `<div class="address-card-line">${escapeHtml(addr.addressLine2)}</div>` : ''}
|
|
47
|
+
<div class="address-card-line">${escapeHtml(`${addr.city}, ${addr.region} ${addr.postalCode}`)}</div>
|
|
48
|
+
<div class="address-card-line">${escapeHtml(addr.country)}</div>
|
|
49
|
+
</div>
|
|
50
|
+
`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loadAddresses() {
|
|
54
|
+
if (!container) return
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const addresses = await client.customer.addresses.list()
|
|
58
|
+
|
|
59
|
+
if (addresses.length === 0) {
|
|
60
|
+
container.innerHTML = `
|
|
61
|
+
<div class="account-empty">
|
|
62
|
+
<h2 class="account-empty-title">No saved addresses</h2>
|
|
63
|
+
<p class="account-empty-text">Add an address below to get started.</p>
|
|
64
|
+
</div>
|
|
65
|
+
`
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
container.innerHTML = `<div class="address-list">${addresses.map(renderAddress).join('')}</div>`
|
|
70
|
+
} catch {
|
|
71
|
+
container.innerHTML = `
|
|
72
|
+
<div class="account-empty">
|
|
73
|
+
<p class="account-empty-text">Unable to load addresses.</p>
|
|
74
|
+
</div>
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
submitOnce(form, async () => {
|
|
80
|
+
if (!form || !messageEl) return
|
|
81
|
+
|
|
82
|
+
const formData = new FormData(form)
|
|
83
|
+
const field = (name: string) => String(formData.get(name) ?? '').trim()
|
|
84
|
+
const address: Address = {
|
|
85
|
+
firstName: field('firstName'),
|
|
86
|
+
lastName: field('lastName'),
|
|
87
|
+
addressLine1: field('addressLine1'),
|
|
88
|
+
city: field('city'),
|
|
89
|
+
region: field('region'),
|
|
90
|
+
postalCode: field('postalCode'),
|
|
91
|
+
country: field('country'),
|
|
92
|
+
...(field('addressLine2') && { addressLine2: field('addressLine2') }),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await client.customer.addresses.create(address)
|
|
97
|
+
messageEl.textContent = 'Address saved successfully.'
|
|
98
|
+
messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
|
|
99
|
+
messageEl.style.display = 'block'
|
|
100
|
+
form.reset()
|
|
101
|
+
await loadAddresses()
|
|
102
|
+
} catch {
|
|
103
|
+
messageEl.textContent = 'Failed to save address. Please try again.'
|
|
104
|
+
messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
|
|
105
|
+
messageEl.style.display = 'block'
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
loadAddresses()
|
|
110
|
+
</script>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/account.css'
|
|
3
|
+
import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<AccountLayout title="My Account">
|
|
7
|
+
<h1 class="account-page-title">Dashboard</h1>
|
|
8
|
+
|
|
9
|
+
<div id="account-dashboard" data-testid="account-dashboard">
|
|
10
|
+
<div class="account-stats">
|
|
11
|
+
<div class="account-stat-card">
|
|
12
|
+
<div class="account-stat-label">Welcome back</div>
|
|
13
|
+
<div class="account-stat-value" id="dashboard-name">--</div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="account-stat-card">
|
|
16
|
+
<div class="account-stat-label">Email</div>
|
|
17
|
+
<div class="account-stat-value" id="dashboard-email" style="font-size: var(--nymbal-font-size-sm, 0.875rem);">--</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="account-stat-card">
|
|
20
|
+
<div class="account-stat-label">Member since</div>
|
|
21
|
+
<div class="account-stat-value" id="dashboard-since" style="font-size: var(--nymbal-font-size-sm, 0.875rem);">--</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<h2 class="section-title">Quick Links</h2>
|
|
26
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--nymbal-spacing-md, 1rem);">
|
|
27
|
+
<a href="/account/orders" class="category-card" style="min-height: auto; padding: var(--nymbal-spacing-md, 1rem);">
|
|
28
|
+
<span class="category-card-name">Order History</span>
|
|
29
|
+
</a>
|
|
30
|
+
<a href="/account/addresses" class="category-card" style="min-height: auto; padding: var(--nymbal-spacing-md, 1rem);">
|
|
31
|
+
<span class="category-card-name">Addresses</span>
|
|
32
|
+
</a>
|
|
33
|
+
<a href="/account/profile" class="category-card" style="min-height: auto; padding: var(--nymbal-spacing-md, 1rem);">
|
|
34
|
+
<span class="category-card-name">Profile Settings</span>
|
|
35
|
+
</a>
|
|
36
|
+
<a href="/products" class="category-card" style="min-height: auto; padding: var(--nymbal-spacing-md, 1rem);">
|
|
37
|
+
<span class="category-card-name">Continue Shopping</span>
|
|
38
|
+
</a>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</AccountLayout>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
import { client } from '../../lib/client.js'
|
|
45
|
+
import { formatDate } from '../../lib/format.js'
|
|
46
|
+
|
|
47
|
+
function renderDashboard() {
|
|
48
|
+
const profile = client.auth.getState().customer
|
|
49
|
+
|
|
50
|
+
const nameEl = document.getElementById('dashboard-name')
|
|
51
|
+
const emailEl = document.getElementById('dashboard-email')
|
|
52
|
+
const sinceEl = document.getElementById('dashboard-since')
|
|
53
|
+
|
|
54
|
+
if (nameEl && profile) {
|
|
55
|
+
nameEl.textContent = [profile.firstName, profile.lastName].filter(Boolean).join(' ') || 'User'
|
|
56
|
+
}
|
|
57
|
+
if (emailEl && profile) {
|
|
58
|
+
emailEl.textContent = profile.email || '--'
|
|
59
|
+
}
|
|
60
|
+
if (sinceEl && profile?.createdAt) {
|
|
61
|
+
sinceEl.textContent = formatDate(profile.createdAt)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
client.auth.subscribe(renderDashboard)
|
|
66
|
+
renderDashboard()
|
|
67
|
+
</script>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/account.css'
|
|
3
|
+
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<BaseLayout title="Sign In | Nymbal" description="Sign in to your Nymbal account.">
|
|
7
|
+
<div class="auth-page">
|
|
8
|
+
<div class="auth-card">
|
|
9
|
+
<h1 class="auth-card-title">Sign In</h1>
|
|
10
|
+
<p class="auth-card-subtitle">Welcome back. Enter your credentials to continue.</p>
|
|
11
|
+
|
|
12
|
+
<div class="checkout-error" id="login-error" style="display:none; margin-bottom: var(--nymbal-spacing-md, 1rem);" role="alert"></div>
|
|
13
|
+
|
|
14
|
+
<form id="login-form" data-testid="login-form">
|
|
15
|
+
<div class="form-group">
|
|
16
|
+
<label for="login-email">Email</label>
|
|
17
|
+
<input
|
|
18
|
+
type="email"
|
|
19
|
+
id="login-email"
|
|
20
|
+
name="email"
|
|
21
|
+
required
|
|
22
|
+
autocomplete="email"
|
|
23
|
+
placeholder="you@example.com"
|
|
24
|
+
data-testid="login-email"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="form-group">
|
|
29
|
+
<label for="login-password">Password</label>
|
|
30
|
+
<input
|
|
31
|
+
type="password"
|
|
32
|
+
id="login-password"
|
|
33
|
+
name="password"
|
|
34
|
+
required
|
|
35
|
+
autocomplete="current-password"
|
|
36
|
+
placeholder="Your password"
|
|
37
|
+
data-testid="login-password"
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<button type="submit" class="btn btn-primary" data-testid="login-submit">
|
|
42
|
+
Sign In
|
|
43
|
+
</button>
|
|
44
|
+
</form>
|
|
45
|
+
|
|
46
|
+
<p class="auth-footer">
|
|
47
|
+
Don't have an account? <a href="/account/register">Create one</a>
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</BaseLayout>
|
|
52
|
+
|
|
53
|
+
<script>
|
|
54
|
+
import '../../scripts/auth-forms.js'
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../../styles/pages/account.css'
|
|
3
|
+
import '../../../styles/pages/confirmation.css'
|
|
4
|
+
import AccountLayout from '../../../layouts/AccountLayout.astro'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<AccountLayout title="Order Detail">
|
|
8
|
+
<div id="order-detail-container" data-testid="order-detail" data-order-number={Astro.params.orderNumber}>
|
|
9
|
+
<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: var(--nymbal-font-size-sm, 0.875rem);">
|
|
10
|
+
Loading order details...
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
</AccountLayout>
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
import { client } from '../../../lib/client.js'
|
|
17
|
+
import { escapeHtml, formatPrice, formatDate } from '../../../lib/format.js'
|
|
18
|
+
|
|
19
|
+
const container = document.getElementById('order-detail-container')
|
|
20
|
+
const orderNumber = container?.dataset.orderNumber
|
|
21
|
+
|
|
22
|
+
async function loadOrderDetail() {
|
|
23
|
+
if (!container || !orderNumber) return
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const order = await client.customer.orders.get(orderNumber)
|
|
27
|
+
const ship = order.shippingAddress
|
|
28
|
+
|
|
29
|
+
container.innerHTML = `
|
|
30
|
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--nymbal-spacing-lg, 1.5rem); flex-wrap: wrap; gap: var(--nymbal-spacing-sm, 0.5rem);">
|
|
31
|
+
<div>
|
|
32
|
+
<h1 class="account-page-title" style="margin-bottom: 0;">Order #${escapeHtml(order.orderNumber)}</h1>
|
|
33
|
+
${order.createdAt ? `<p class="account-page-subtitle" style="margin-top: var(--nymbal-spacing-xs, 0.25rem);">${formatDate(order.createdAt)}</p>` : ''}
|
|
34
|
+
</div>
|
|
35
|
+
<span class="order-status status-${escapeHtml(order.status)}">${escapeHtml(order.status)}</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="confirmation-card">
|
|
39
|
+
<h2 class="confirmation-card-title">Items</h2>
|
|
40
|
+
<div class="confirmation-items" style="margin-top: 0; padding-top: 0; border-top: none;">
|
|
41
|
+
${(order.lineItems ?? [])
|
|
42
|
+
.map(
|
|
43
|
+
(item) => `
|
|
44
|
+
<div class="confirmation-item">
|
|
45
|
+
<span>
|
|
46
|
+
<span class="confirmation-item-name">${escapeHtml(item.productName)}</span>
|
|
47
|
+
${item.variantName ? ` - ${escapeHtml(item.variantName)}` : ''}
|
|
48
|
+
<span class="confirmation-item-qty"> x${item.qty}</span>
|
|
49
|
+
</span>
|
|
50
|
+
<span class="confirmation-item-price">${formatPrice(item.lineTotalMinor, order.currency)}</span>
|
|
51
|
+
</div>
|
|
52
|
+
`,
|
|
53
|
+
)
|
|
54
|
+
.join('')}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="confirmation-totals">
|
|
58
|
+
<div class="confirmation-total-row">
|
|
59
|
+
<span>Subtotal</span>
|
|
60
|
+
<span>${formatPrice(order.subtotalMinor, order.currency)}</span>
|
|
61
|
+
</div>
|
|
62
|
+
${order.shippingTotalMinor != null ? `
|
|
63
|
+
<div class="confirmation-total-row">
|
|
64
|
+
<span>Shipping</span>
|
|
65
|
+
<span>${order.shippingTotalMinor === 0 ? 'Free' : formatPrice(order.shippingTotalMinor, order.currency)}</span>
|
|
66
|
+
</div>
|
|
67
|
+
` : ''}
|
|
68
|
+
${order.taxTotalMinor != null ? `
|
|
69
|
+
<div class="confirmation-total-row">
|
|
70
|
+
<span>Tax</span>
|
|
71
|
+
<span>${formatPrice(order.taxTotalMinor, order.currency)}</span>
|
|
72
|
+
</div>
|
|
73
|
+
` : ''}
|
|
74
|
+
<div class="confirmation-total-row grand-total">
|
|
75
|
+
<span>Total</span>
|
|
76
|
+
<span>${formatPrice(order.totalMinor, order.currency)}</span>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
${ship ? `
|
|
82
|
+
<div class="confirmation-card">
|
|
83
|
+
<h2 class="confirmation-card-title">Shipping Address</h2>
|
|
84
|
+
<p style="font-size: var(--nymbal-font-size-sm, 0.875rem); color: var(--nymbal-color-text-muted, #71717a); line-height: 1.6;">
|
|
85
|
+
${escapeHtml(`${ship.firstName} ${ship.lastName}`)}<br/>
|
|
86
|
+
${escapeHtml(ship.addressLine1)}<br/>
|
|
87
|
+
${ship.addressLine2 ? escapeHtml(ship.addressLine2) + '<br/>' : ''}
|
|
88
|
+
${escapeHtml(`${ship.city}, ${ship.region} ${ship.postalCode}`)}<br/>
|
|
89
|
+
${escapeHtml(ship.country)}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
` : ''}
|
|
93
|
+
|
|
94
|
+
<a href="/account/orders" class="btn btn-secondary" style="margin-top: var(--nymbal-spacing-md, 1rem);">Back to Orders</a>
|
|
95
|
+
`
|
|
96
|
+
} catch {
|
|
97
|
+
container.innerHTML = `
|
|
98
|
+
<div class="account-empty">
|
|
99
|
+
<h2 class="account-empty-title">Unable to load order</h2>
|
|
100
|
+
<p class="account-empty-text">Please try again later.</p>
|
|
101
|
+
<a href="/account/orders" class="btn btn-secondary">Back to Orders</a>
|
|
102
|
+
</div>
|
|
103
|
+
`
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
loadOrderDetail()
|
|
108
|
+
</script>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/account.css'
|
|
3
|
+
import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<AccountLayout title="Order History">
|
|
7
|
+
<h1 class="account-page-title">Order History</h1>
|
|
8
|
+
|
|
9
|
+
<div id="orders-container" data-testid="orders-list">
|
|
10
|
+
<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: var(--nymbal-font-size-sm, 0.875rem);">
|
|
11
|
+
Loading orders...
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
</AccountLayout>
|
|
15
|
+
|
|
16
|
+
<script>
|
|
17
|
+
import type { Order } from '@nymbalhq/types'
|
|
18
|
+
import { client } from '../../lib/client.js'
|
|
19
|
+
import { escapeHtml, formatPrice, formatDate } from '../../lib/format.js'
|
|
20
|
+
|
|
21
|
+
const container = document.getElementById('orders-container')
|
|
22
|
+
|
|
23
|
+
function renderOrder(order: Order): string {
|
|
24
|
+
const number = escapeHtml(order.orderNumber)
|
|
25
|
+
const items = order.lineItems ?? []
|
|
26
|
+
return `
|
|
27
|
+
<a class="order-card" href="/account/orders/${encodeURIComponent(order.orderNumber)}" data-testid="order-${number}">
|
|
28
|
+
<div class="order-card-header">
|
|
29
|
+
<div>
|
|
30
|
+
<span class="order-card-number">#${number}</span>
|
|
31
|
+
${order.createdAt ? `<span class="order-card-date" style="margin-left: var(--nymbal-spacing-sm, 0.5rem);">${formatDate(order.createdAt)}</span>` : ''}
|
|
32
|
+
</div>
|
|
33
|
+
<span class="order-status status-${escapeHtml(order.status)}">${escapeHtml(order.status)}</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="order-card-body">
|
|
36
|
+
<div class="order-card-items">
|
|
37
|
+
${items
|
|
38
|
+
.slice(0, 3)
|
|
39
|
+
.map((item) => `<span>${escapeHtml(item.productName)} x${item.qty}</span>`)
|
|
40
|
+
.join('')}
|
|
41
|
+
${items.length > 3 ? `<span>+${items.length - 3} more items</span>` : ''}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="order-card-footer">
|
|
45
|
+
<span class="order-card-total">${formatPrice(order.totalMinor, order.currency)}</span>
|
|
46
|
+
<span class="order-card-link">View Details</span>
|
|
47
|
+
</div>
|
|
48
|
+
</a>
|
|
49
|
+
`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function loadOrders() {
|
|
53
|
+
if (!container) return
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { items: orders } = await client.customer.orders.list()
|
|
57
|
+
|
|
58
|
+
if (orders.length === 0) {
|
|
59
|
+
container.innerHTML = `
|
|
60
|
+
<div class="account-empty">
|
|
61
|
+
<h2 class="account-empty-title">No orders yet</h2>
|
|
62
|
+
<p class="account-empty-text">Once you place an order, it will appear here.</p>
|
|
63
|
+
<a href="/products" class="btn btn-primary">Start Shopping</a>
|
|
64
|
+
</div>
|
|
65
|
+
`
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
container.innerHTML = `<div class="order-list">${orders.map(renderOrder).join('')}</div>`
|
|
70
|
+
} catch {
|
|
71
|
+
container.innerHTML = `
|
|
72
|
+
<div class="account-empty">
|
|
73
|
+
<h2 class="account-empty-title">Unable to load orders</h2>
|
|
74
|
+
<p class="account-empty-text">Please try again later.</p>
|
|
75
|
+
</div>
|
|
76
|
+
`
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
loadOrders()
|
|
81
|
+
</script>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/account.css'
|
|
3
|
+
import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<AccountLayout title="Profile">
|
|
7
|
+
<h1 class="account-page-title">Profile</h1>
|
|
8
|
+
<p class="account-page-subtitle">Update your personal information.</p>
|
|
9
|
+
|
|
10
|
+
<form id="profile-form" class="account-form" data-testid="profile-form">
|
|
11
|
+
<div class="account-form-row">
|
|
12
|
+
<div class="form-group">
|
|
13
|
+
<label for="profile-firstName">First Name</label>
|
|
14
|
+
<input
|
|
15
|
+
type="text"
|
|
16
|
+
id="profile-firstName"
|
|
17
|
+
name="firstName"
|
|
18
|
+
autocomplete="given-name"
|
|
19
|
+
data-testid="profile-firstName"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="form-group">
|
|
24
|
+
<label for="profile-lastName">Last Name</label>
|
|
25
|
+
<input
|
|
26
|
+
type="text"
|
|
27
|
+
id="profile-lastName"
|
|
28
|
+
name="lastName"
|
|
29
|
+
autocomplete="family-name"
|
|
30
|
+
data-testid="profile-lastName"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="form-group">
|
|
36
|
+
<label for="profile-email">Email</label>
|
|
37
|
+
<input
|
|
38
|
+
type="email"
|
|
39
|
+
id="profile-email"
|
|
40
|
+
name="email"
|
|
41
|
+
disabled
|
|
42
|
+
autocomplete="email"
|
|
43
|
+
data-testid="profile-email"
|
|
44
|
+
style="opacity: 0.6; cursor: not-allowed;"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="form-group">
|
|
49
|
+
<label for="profile-phone">Phone</label>
|
|
50
|
+
<input
|
|
51
|
+
type="tel"
|
|
52
|
+
id="profile-phone"
|
|
53
|
+
name="phone"
|
|
54
|
+
autocomplete="tel"
|
|
55
|
+
data-testid="profile-phone"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="account-form-actions">
|
|
60
|
+
<button type="submit" class="btn btn-primary" data-testid="profile-save">Save Changes</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<p class="form-message" id="profile-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
|
|
64
|
+
</form>
|
|
65
|
+
</AccountLayout>
|
|
66
|
+
|
|
67
|
+
<script>
|
|
68
|
+
import '../../scripts/profile-form.js'
|
|
69
|
+
</script>
|