@redeemid/emdash 0.0.17 → 0.0.18
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/README.md +50 -18
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/package.json +3 -1
- package/src/agents.ts +55 -0
- package/src/api-version.ts +6 -0
- package/src/blocks/CollectionBlock.astro +4 -1
- package/src/blocks/SearchBlock.astro +14 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/cart-ops.ts +164 -0
- package/src/catalog.ts +287 -0
- package/src/checkout.ts +73 -0
- package/src/client/redeemid.ts +563 -0
- package/src/components/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +11 -5
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +1 -0
- package/src/components/Collection.astro +22 -17
- package/src/components/Content.astro +3 -2
- package/src/components/LockedPanel.astro +4 -2
- package/src/components/Notices.astro +2 -27
- package/src/components/Product.astro +115 -24
- package/src/components/ProductGrid.astro +52 -0
- package/src/components/ProductSearch.astro +83 -0
- package/src/components/Recommendations.astro +45 -0
- package/src/components/RedeemIDClient.astro +45 -0
- package/src/config.ts +14 -0
- package/src/contract.ts +50 -0
- package/src/index.ts +3 -0
- package/src/notices.ts +29 -0
- package/src/plugin.ts +32 -2
- package/src/rate-limit.ts +7 -0
- package/src/routes/api/cart.ts +84 -0
- package/src/routes/api/search.ts +49 -0
- package/src/routes/api/shared.ts +4 -0
- package/src/routes/api/state.ts +69 -0
- package/src/routes/buy.ts +4 -3
- package/src/routes/callback.ts +2 -1
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/shared.ts +11 -0
- package/src/shopify.ts +37 -1
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A cart that slides in over the page. Put it once in the site layout,
|
|
4
|
+
* with <RedeemIDClient />. Add to cart then opens it in place, and
|
|
5
|
+
* <Cart drawer /> opens it instead of going to checkout. It is a native
|
|
6
|
+
* modal dialog: focus stays inside while it's open, Escape closes it, and
|
|
7
|
+
* focus goes back to what opened it.
|
|
8
|
+
*
|
|
9
|
+
* Without JavaScript it never shows, and the cart forms and links work as
|
|
10
|
+
* they do on their own.
|
|
11
|
+
*/
|
|
12
|
+
import { siteConfig } from "../site-config.js";
|
|
13
|
+
import { text } from "../text.js";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
title?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config = await siteConfig(Astro.locals);
|
|
20
|
+
const { title = text(config, "Your cart") } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<dialog class="redeemid-drawer" data-redeemid-cart-drawer aria-labelledby="redeemid-drawer-title">
|
|
24
|
+
<div class="redeemid-drawer__head">
|
|
25
|
+
<h2 id="redeemid-drawer-title">{title}</h2>
|
|
26
|
+
<button type="button" class="redeemid-drawer__close" data-redeemid-drawer-close>{text(config, "Close")}</button>
|
|
27
|
+
</div>
|
|
28
|
+
<p class="redeemid-drawer__status" role="status" data-redeemid-drawer-status></p>
|
|
29
|
+
<div class="redeemid-cartview redeemid-drawer__body" data-redeemid-drawer-body></div>
|
|
30
|
+
</dialog>
|
|
31
|
+
|
|
32
|
+
<style is:global>
|
|
33
|
+
/* Low-specificity defaults; a site's own styles win. */
|
|
34
|
+
:where(dialog.redeemid-drawer) {
|
|
35
|
+
box-sizing: border-box;
|
|
36
|
+
inset: 0 0 0 auto;
|
|
37
|
+
margin: 0;
|
|
38
|
+
height: 100dvh;
|
|
39
|
+
max-height: 100dvh;
|
|
40
|
+
width: min(28rem, 100vw);
|
|
41
|
+
max-width: 100vw;
|
|
42
|
+
padding: 1rem;
|
|
43
|
+
border: 0;
|
|
44
|
+
overflow-y: auto;
|
|
45
|
+
}
|
|
46
|
+
:where(dialog.redeemid-drawer)::backdrop {
|
|
47
|
+
background: rgb(0 0 0 / 0.4);
|
|
48
|
+
}
|
|
49
|
+
:where(.redeemid-drawer img) {
|
|
50
|
+
max-width: 5rem;
|
|
51
|
+
height: auto;
|
|
52
|
+
}
|
|
53
|
+
:where(.redeemid-drawer__head) {
|
|
54
|
+
display: flex;
|
|
55
|
+
justify-content: space-between;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 1rem;
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
@@ -57,6 +57,7 @@ const code = cart?.discountCodes[0];
|
|
|
57
57
|
<p class="redeemid-cartview__name">
|
|
58
58
|
{href(l.productHandle) ? <a href={href(l.productHandle)}>{l.productTitle}</a> : l.productTitle}
|
|
59
59
|
{l.variantTitle && l.variantTitle !== "Default Title" && <span> · {l.variantTitle}</span>}
|
|
60
|
+
{l.sellingPlan && <span> · {l.sellingPlan}</span>}
|
|
60
61
|
</p>
|
|
61
62
|
<p class="redeemid-cartview__price">{text(config, "{price} each · {total}", { price: money(l.price), total: money(l.total) })}</p>
|
|
62
63
|
<form method="post" action={action} class="redeemid-cartview__quantity">
|
|
@@ -5,35 +5,47 @@
|
|
|
5
5
|
* site can give every product its own page with <Product>.
|
|
6
6
|
*
|
|
7
7
|
* <Collection handle="shop" productHref="/shop/{handle}/" />
|
|
8
|
+
* <Collection handle="shop" productHref="/shop/{handle}/" browse />
|
|
9
|
+
*
|
|
10
|
+
* `browse` adds Shopify's filters, sort orders and paging, read from the
|
|
11
|
+
* page URL (so the page must be rendered on demand). `quickAdd` adds Add to
|
|
12
|
+
* cart on cards that need no choice.
|
|
8
13
|
*/
|
|
14
|
+
import { browseFromUrl, collectionPage, type CollectionPage } from "../catalog.js";
|
|
9
15
|
import { siteConfig } from "../site-config.js";
|
|
10
|
-
import { collection as loadCollection,
|
|
11
|
-
import
|
|
16
|
+
import { collection as loadCollection, storeContext, type Collection } from "../storefront.js";
|
|
17
|
+
import BrowseControls from "./BrowseControls.astro";
|
|
18
|
+
import ProductGrid from "./ProductGrid.astro";
|
|
12
19
|
|
|
13
20
|
interface Props {
|
|
14
21
|
handle: string;
|
|
15
|
-
/** How many products, 1 to 50. */
|
|
22
|
+
/** How many products (per page with `browse`), 1 to 50. */
|
|
16
23
|
limit?: number;
|
|
17
24
|
/** Link for each product; `{handle}` is replaced. Leave out for cards without links. */
|
|
18
25
|
productHref?: string;
|
|
19
26
|
/** Show the collection's title and description above the grid. */
|
|
20
27
|
showTitle?: boolean;
|
|
28
|
+
/** Filters, sorting and paging from the page URL. */
|
|
29
|
+
browse?: boolean;
|
|
30
|
+
quickAdd?: boolean;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
const { handle, limit = 12, productHref, showTitle = false } = Astro.props;
|
|
33
|
+
const { handle, limit = 12, productHref, showTitle = false, browse = false, quickAdd = false } = Astro.props;
|
|
24
34
|
const config = await siteConfig(Astro.locals);
|
|
25
|
-
|
|
35
|
+
const context = storeContext(config, Astro.request);
|
|
36
|
+
let c: Collection | CollectionPage | null = null;
|
|
26
37
|
if (config) {
|
|
27
38
|
try {
|
|
28
|
-
c = await loadCollection(config, handle, limit);
|
|
39
|
+
c = browse ? await collectionPage(config, handle, browseFromUrl(Astro.url.searchParams, limit), { context }) : await loadCollection(config, handle, limit, undefined, context);
|
|
29
40
|
} catch {
|
|
30
41
|
c = null;
|
|
31
42
|
}
|
|
32
43
|
}
|
|
33
|
-
const
|
|
44
|
+
const page = c && "pageInfo" in c ? c : null;
|
|
45
|
+
const id = `redeemid-collection-${handle}`;
|
|
34
46
|
---
|
|
35
47
|
|
|
36
|
-
{c && c.products.length > 0 ? (
|
|
48
|
+
{c && (c.products.length > 0 || page) ? (
|
|
37
49
|
<section class="redeemid-collection" data-redeemid-collection={c.handle}>
|
|
38
50
|
{showTitle && (
|
|
39
51
|
<>
|
|
@@ -41,15 +53,8 @@ const href = (h: string) => productHref?.replaceAll("{handle}", encodeURICompone
|
|
|
41
53
|
{c.description && <p class="redeemid-collection__description">{c.description}</p>}
|
|
42
54
|
</>
|
|
43
55
|
)}
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
<li class="redeemid-collection__item" data-redeemid-product={p.handle}>
|
|
47
|
-
{p.image && <img src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
|
|
48
|
-
<h3>{href(p.handle) ? <a href={href(p.handle)}>{p.title}</a> : p.title}</h3>
|
|
49
|
-
<p class="redeemid-collection__price">{p.availableForSale ? (p.price ? text(config, "From {price}", { price: money(p.price) }) : "") : text(config, "Sold out")}</p>
|
|
50
|
-
</li>
|
|
51
|
-
))}
|
|
52
|
-
</ul>
|
|
56
|
+
{page && <BrowseControls filters={page.filters} pageInfo={page.pageInfo} id={id} />}
|
|
57
|
+
<ProductGrid products={c.products} productHref={productHref} quickAdd={quickAdd} />
|
|
53
58
|
</section>
|
|
54
59
|
) : (
|
|
55
60
|
<slot />
|
|
@@ -122,7 +122,7 @@ const meterText =
|
|
|
122
122
|
!p.verdict || p.verdict.allowed ? (
|
|
123
123
|
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
124
124
|
) : (
|
|
125
|
-
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} />
|
|
125
|
+
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} entry={id} />
|
|
126
126
|
),
|
|
127
127
|
)}
|
|
128
128
|
{verdict.allowed ? (
|
|
@@ -130,7 +130,7 @@ const meterText =
|
|
|
130
130
|
!p.verdict || p.verdict.allowed ? (
|
|
131
131
|
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
132
132
|
) : (
|
|
133
|
-
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} />
|
|
133
|
+
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} entry={id} />
|
|
134
134
|
),
|
|
135
135
|
)
|
|
136
136
|
) : (
|
|
@@ -142,6 +142,7 @@ const meterText =
|
|
|
142
142
|
meterLeft={verdict.meterLeft}
|
|
143
143
|
meterPeriod={verdict.meter?.period}
|
|
144
144
|
item={item}
|
|
145
|
+
entry={id}
|
|
145
146
|
>
|
|
146
147
|
<slot name="locked" />
|
|
147
148
|
</LockedPanel>
|
|
@@ -27,9 +27,11 @@ interface Props {
|
|
|
27
27
|
meterPeriod?: "month" | "week";
|
|
28
28
|
/** The entry's "Sold on its own" block: offers "Buy just this". */
|
|
29
29
|
item?: SoldItem | null;
|
|
30
|
+
/** The entry's id or slug, for the browser client's wall_shown event. */
|
|
31
|
+
entry?: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod, item } = Astro.props;
|
|
34
|
+
const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod, item, entry } = Astro.props;
|
|
33
35
|
const opens = opensAt
|
|
34
36
|
? new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(opensAt)
|
|
35
37
|
: null;
|
|
@@ -54,7 +56,7 @@ if (item?.product && config && (reason === "sign_in" || reason === "upgrade" ||
|
|
|
54
56
|
const here = Astro.url.pathname + Astro.url.search;
|
|
55
57
|
---
|
|
56
58
|
|
|
57
|
-
<div class="redeemid-locked" data-redeemid-reason={reason} data-redeemid-level={level?.slug}>
|
|
59
|
+
<div class="redeemid-locked" data-redeemid-reason={reason} data-redeemid-level={level?.slug} data-redeemid-entry={entry}>
|
|
58
60
|
<slot />
|
|
59
61
|
{level?.wall && (reason === "sign_in" || reason === "upgrade") && <p class="redeemid-locked__wall">{level.wall}</p>}
|
|
60
62
|
{reason === "sign_in" && (
|
|
@@ -5,39 +5,14 @@
|
|
|
5
5
|
* `messages` to reword any of them (config `text` does too, for every page).
|
|
6
6
|
*/
|
|
7
7
|
import { siteConfig } from "../site-config.js";
|
|
8
|
+
import { NOTICES as DEFAULTS } from "../notices.js";
|
|
8
9
|
import { text as replace } from "../text.js";
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
12
|
messages?: Record<string, string>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
not_configured: "Sign-in isn't set up on this site yet.",
|
|
16
|
-
sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
|
|
17
|
-
sign_in_expired: "That sign-in took too long. Try again.",
|
|
18
|
-
staff_account: "This account can't sign in here. Use a different email.",
|
|
19
|
-
account_disabled: "Your account is turned off. Contact the store for help.",
|
|
20
|
-
too_many_requests: "Too many tries. Wait a minute, then try again.",
|
|
21
|
-
site_not_ready: "This site isn't ready for sign-in yet.",
|
|
22
|
-
email_required: "Your account needs an email address to sign in.",
|
|
23
|
-
token_failed: "Sign-in didn't finish. Try again.",
|
|
24
|
-
bad_id_token: "Sign-in didn't finish. Try again.",
|
|
25
|
-
identity_failed: "Sign-in didn't finish. Try again.",
|
|
26
|
-
plan_unavailable: "That plan isn't available right now.",
|
|
27
|
-
checkout_failed: "Checkout didn't open. Try again.",
|
|
28
|
-
cart_empty: "Your cart is empty.",
|
|
29
|
-
cart_added: "Added to your cart.",
|
|
30
|
-
cart_updated: "Your cart is updated.",
|
|
31
|
-
discount_applied: "Discount applied.",
|
|
32
|
-
discount_not_applicable: "That discount code doesn't apply to this cart.",
|
|
33
|
-
refreshed: "Your membership is up to date.",
|
|
34
|
-
preferences_saved: "Your preferences are saved.",
|
|
35
|
-
preferences_refused: "Some answers weren't saved. Check them and try again.",
|
|
36
|
-
preferences_unavailable: "Your preferences can't be saved right now. Try again in a few minutes.",
|
|
37
|
-
data_erased: "Your data on this site is deleted, and you're signed out.",
|
|
38
|
-
erase_unconfirmed: "Tick the box to confirm, then try again.",
|
|
39
|
-
account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
|
|
40
|
-
};
|
|
15
|
+
|
|
41
16
|
|
|
42
17
|
const { messages = {} } = Astro.props;
|
|
43
18
|
const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
|
|
@@ -1,72 +1,163 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* A Shopify product:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* A Shopify product: images, title, price (with the sale price when there
|
|
4
|
+
* is one), description, a picker per option (Size, Color…), a choice of
|
|
5
|
+
* one-time or subscription when the product has selling plans, a quantity,
|
|
6
|
+
* and "Add to cart" and "Buy now". Everything comes from the Storefront
|
|
7
|
+
* API; the default slot shows if the product can't be loaded.
|
|
7
8
|
*
|
|
8
|
-
* <Product handle="harbor-tote" />
|
|
9
|
+
* <Product handle="harbor-tote" heading="h1" />
|
|
10
|
+
*
|
|
11
|
+
* The form works without JavaScript (the server picks the variant from the
|
|
12
|
+
* options). With <RedeemIDClient /> on the page the price, image and stock
|
|
13
|
+
* follow the choice as it changes, and with <CartDrawer /> Add to cart opens
|
|
14
|
+
* the drawer in place.
|
|
15
|
+
*
|
|
16
|
+
* On a product's own page (heading "h1") it prints Product structured data
|
|
17
|
+
* for search engines; `schema={false}` turns that off.
|
|
9
18
|
*/
|
|
10
19
|
import { siteConfig } from "../site-config.js";
|
|
11
|
-
import { money, product as loadProduct, type Product } from "../storefront.js";
|
|
20
|
+
import { money, product as loadProduct, realOptions, storeContext, type Product } from "../storefront.js";
|
|
12
21
|
import { text } from "../text.js";
|
|
13
22
|
|
|
14
23
|
interface Props {
|
|
15
24
|
handle: string;
|
|
16
25
|
/** Heading level for the title. */
|
|
17
26
|
heading?: "h1" | "h2" | "h3";
|
|
18
|
-
/** Leave out the description (for cards). */
|
|
27
|
+
/** Leave out the description and gallery (for cards). */
|
|
19
28
|
compact?: boolean;
|
|
20
29
|
addLabel?: string;
|
|
21
30
|
buyLabel?: string;
|
|
31
|
+
/** Show Shopify's HTML description (as the merchant wrote it) instead of the plain text. */
|
|
32
|
+
html?: boolean;
|
|
33
|
+
/** Product structured data. Defaults to on for heading "h1". */
|
|
34
|
+
schema?: boolean;
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
const config = await siteConfig(Astro.locals);
|
|
25
|
-
const { handle, heading = "h2", compact = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
|
|
38
|
+
const { handle, heading = "h2", compact = false, html = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
|
|
39
|
+
const context = storeContext(config, Astro.request);
|
|
26
40
|
let p: Product | null = null;
|
|
27
41
|
if (config) {
|
|
28
42
|
try {
|
|
29
|
-
p = await loadProduct(config, handle);
|
|
43
|
+
p = await loadProduct(config, handle, undefined, context);
|
|
30
44
|
} catch {
|
|
31
45
|
p = null;
|
|
32
46
|
}
|
|
33
47
|
}
|
|
48
|
+
if (context.country && config?.market?.country === "visitor") Astro.response.headers.set("Vary", "CF-IPCountry");
|
|
49
|
+
const options = p ? realOptions(p) : [];
|
|
34
50
|
const available = p?.variants.filter((v) => v.availableForSale) ?? [];
|
|
35
51
|
const first = available[0] ?? p?.variants[0];
|
|
52
|
+
const chosen = Object.fromEntries((first?.selectedOptions ?? []).map((o) => [o.name, o.value]));
|
|
53
|
+
const plans = p?.sellingPlans ?? [];
|
|
54
|
+
const sale = first?.compareAtPrice && Number(first.compareAtPrice.amount) > Number(first.price.amount) ? first.compareAtPrice : null;
|
|
55
|
+
const image = first?.image ?? p?.image ?? null;
|
|
56
|
+
const images = p?.images ?? (p?.image ? [p.image] : []);
|
|
36
57
|
const here = Astro.url.pathname + Astro.url.search;
|
|
37
58
|
const Heading = heading;
|
|
38
59
|
const id = `redeemid-product-${handle}`;
|
|
60
|
+
const variants = (p?.variants ?? []).map((v) => ({
|
|
61
|
+
id: v.id,
|
|
62
|
+
available: v.availableForSale,
|
|
63
|
+
price: money(v.price) ?? "",
|
|
64
|
+
compareAt: v.compareAtPrice && Number(v.compareAtPrice.amount) > Number(v.price.amount) ? (money(v.compareAtPrice) ?? "") : "",
|
|
65
|
+
image: v.image?.url ?? null,
|
|
66
|
+
imageAlt: v.image?.altText ?? "",
|
|
67
|
+
options: Object.fromEntries((v.selectedOptions ?? []).map((o) => [o.name, o.value])),
|
|
68
|
+
}));
|
|
69
|
+
const json = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
70
|
+
const showSchema = (Astro.props.schema ?? heading === "h1") && config?.schema !== false && p;
|
|
71
|
+
const schema =
|
|
72
|
+
showSchema && p
|
|
73
|
+
? json({
|
|
74
|
+
"@context": "https://schema.org",
|
|
75
|
+
"@type": "Product",
|
|
76
|
+
name: p.title,
|
|
77
|
+
description: p.description || undefined,
|
|
78
|
+
image: images.map((i) => i.url),
|
|
79
|
+
brand: p.vendor ? { "@type": "Brand", name: p.vendor } : undefined,
|
|
80
|
+
url: new URL(Astro.url.pathname, Astro.url).href,
|
|
81
|
+
offers: p.variants.map((v) => ({
|
|
82
|
+
"@type": "Offer",
|
|
83
|
+
price: v.price.amount,
|
|
84
|
+
priceCurrency: v.price.currencyCode,
|
|
85
|
+
availability: v.availableForSale ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
|
86
|
+
sku: v.sku || undefined,
|
|
87
|
+
name: v.title !== "Default Title" ? v.title : undefined,
|
|
88
|
+
})),
|
|
89
|
+
})
|
|
90
|
+
: null;
|
|
39
91
|
---
|
|
40
92
|
|
|
41
93
|
{p ? (
|
|
42
94
|
<div class="redeemid-product" data-redeemid-product={p.handle}>
|
|
43
|
-
{
|
|
95
|
+
{schema && <script type="application/ld+json" set:html={schema} />}
|
|
96
|
+
{image && <img class="redeemid-product__image" src={image.url} alt={image.altText ?? ""} loading={heading === "h1" ? "eager" : "lazy"} data-redeemid-product-image />}
|
|
97
|
+
{!compact && images.length > 1 && (
|
|
98
|
+
<ul class="redeemid-product__thumbs">
|
|
99
|
+
{images.map((img, i) => (
|
|
100
|
+
<li>
|
|
101
|
+
<button type="button" data-redeemid-thumb={img.url} data-redeemid-alt={img.altText ?? ""} aria-pressed={String(i === 0)}>
|
|
102
|
+
<img src={img.url} alt={img.altText || text(config, "Image {number} of {count}", { number: i + 1, count: images.length })} loading="lazy" />
|
|
103
|
+
</button>
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
)}
|
|
44
108
|
<Heading class="redeemid-product__title">{p.title}</Heading>
|
|
45
|
-
|
|
46
|
-
|
|
109
|
+
<p class="redeemid-product__price">
|
|
110
|
+
<span data-redeemid-price>{first && money(first.price)}</span>
|
|
111
|
+
<s class="redeemid-product__compare-at" data-redeemid-compare-at hidden={!sale}>{sale && money(sale)}</s>
|
|
112
|
+
</p>
|
|
113
|
+
{!compact && (html && p.descriptionHtml ? <div class="redeemid-product__description" set:html={p.descriptionHtml} /> : p.description && <p class="redeemid-product__description">{p.description}</p>)}
|
|
47
114
|
{available.length > 0 ? (
|
|
48
|
-
<form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
|
|
115
|
+
<form method="post" action="/_emdash/api/auth/redeemid/cart-edit" data-redeemid-product-form>
|
|
49
116
|
<input type="hidden" name="action" value="add" />
|
|
50
117
|
<input type="hidden" name="product" value={p.handle} />
|
|
51
118
|
<input type="hidden" name="return" value={here} />
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
119
|
+
{options.length > 0 ? (
|
|
120
|
+
<>
|
|
121
|
+
<script type="application/json" data-redeemid-variants set:html={json(variants)} />
|
|
122
|
+
{options.map((o, i) => (
|
|
123
|
+
<p class="redeemid-product__option">
|
|
124
|
+
<label for={`${id}-option-${i}`}>{o.name}</label>
|
|
125
|
+
<select id={`${id}-option-${i}`} name={`option.${o.name}`}>
|
|
126
|
+
{o.values.map((value) => (
|
|
127
|
+
<option value={value} selected={chosen[o.name] === value} disabled={options.length === 1 && !p.variants.some((v) => v.availableForSale && v.selectedOptions?.some((x) => x.name === o.name && x.value === value))}>
|
|
128
|
+
{value}
|
|
129
|
+
</option>
|
|
130
|
+
))}
|
|
131
|
+
</select>
|
|
132
|
+
</p>
|
|
133
|
+
))}
|
|
134
|
+
</>
|
|
63
135
|
) : (
|
|
64
136
|
<input type="hidden" name="variant" value={first?.id} />
|
|
65
137
|
)}
|
|
138
|
+
{plans.length > 0 && (
|
|
139
|
+
<fieldset class="redeemid-product__plans">
|
|
140
|
+
<legend>{text(config, "Purchase options")}</legend>
|
|
141
|
+
{!p.requiresSellingPlan && (
|
|
142
|
+
<p>
|
|
143
|
+
<input type="radio" id={`${id}-plan-none`} name="selling_plan" value="" checked />
|
|
144
|
+
<label for={`${id}-plan-none`}>{text(config, "One-time purchase")}</label>
|
|
145
|
+
</p>
|
|
146
|
+
)}
|
|
147
|
+
{plans.map((sp, i) => (
|
|
148
|
+
<p>
|
|
149
|
+
<input type="radio" id={`${id}-plan-${i}`} name="selling_plan" value={sp.id} checked={p.requiresSellingPlan && i === 0} />
|
|
150
|
+
<label for={`${id}-plan-${i}`}>{sp.group && sp.group !== sp.name ? text(config, "{group}: {plan}", { group: sp.group, plan: sp.name }) : sp.name}</label>
|
|
151
|
+
</p>
|
|
152
|
+
))}
|
|
153
|
+
</fieldset>
|
|
154
|
+
)}
|
|
66
155
|
<p>
|
|
67
156
|
<label for={`${id}-quantity`}>{text(config, "Quantity")}</label>
|
|
68
157
|
<input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
|
|
69
158
|
</p>
|
|
159
|
+
<p class="redeemid-product__availability" role="status" data-redeemid-availability></p>
|
|
160
|
+
<p class="redeemid-product__form-status" role="status" data-redeemid-form-status></p>
|
|
70
161
|
<button type="submit" name="then" value="back">{addLabel}</button>
|
|
71
162
|
<button type="submit" name="then" value="checkout">{buyLabel}</button>
|
|
72
163
|
</form>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Product cards in a grid: image, title, lowest price (with the sale price
|
|
4
|
+
* when there is one). Used by <Collection>, <ProductSearch> and
|
|
5
|
+
* <Recommendations>; use it for your own lists of Storefront products.
|
|
6
|
+
*
|
|
7
|
+
* `quickAdd` puts Add to cart on cards whose product has one variant and
|
|
8
|
+
* needs no choice.
|
|
9
|
+
*/
|
|
10
|
+
import { siteConfig } from "../site-config.js";
|
|
11
|
+
import { money, type ProductCard } from "../storefront.js";
|
|
12
|
+
import { text } from "../text.js";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
products: ProductCard[];
|
|
16
|
+
/** Link for each product; `{handle}` is replaced. */
|
|
17
|
+
productHref?: string;
|
|
18
|
+
/** Heading level of each card's title. */
|
|
19
|
+
heading?: "h2" | "h3" | "h4";
|
|
20
|
+
quickAdd?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { products, productHref, heading = "h3", quickAdd = false } = Astro.props;
|
|
24
|
+
const config = await siteConfig(Astro.locals);
|
|
25
|
+
const href = (h: string) => productHref?.replaceAll("{handle}", encodeURIComponent(h));
|
|
26
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
27
|
+
const Heading = heading;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<ul class="redeemid-collection__grid">
|
|
31
|
+
{products.map((p) => (
|
|
32
|
+
<li class="redeemid-collection__item" data-redeemid-product={p.handle}>
|
|
33
|
+
{p.image && <img src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
|
|
34
|
+
<Heading>{href(p.handle) ? <a href={href(p.handle)}>{p.title}</a> : p.title}</Heading>
|
|
35
|
+
<p class="redeemid-collection__price">
|
|
36
|
+
{p.availableForSale ? (p.price ? text(config, "From {price}", { price: money(p.price) }) : "") : text(config, "Sold out")}
|
|
37
|
+
{p.availableForSale && p.compareAtPrice && <s class="redeemid-collection__compare-at">{money(p.compareAtPrice)}</s>}
|
|
38
|
+
</p>
|
|
39
|
+
{quickAdd && p.quickAddVariantId && (
|
|
40
|
+
<form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
|
|
41
|
+
<input type="hidden" name="action" value="add" />
|
|
42
|
+
<input type="hidden" name="product" value={p.handle} />
|
|
43
|
+
<input type="hidden" name="variant" value={p.quickAddVariantId} />
|
|
44
|
+
<input type="hidden" name="return" value={here} />
|
|
45
|
+
<button type="submit" name="then" value="back">
|
|
46
|
+
{text(config, "Add to cart")}<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> {p.title}</span>
|
|
47
|
+
</button>
|
|
48
|
+
</form>
|
|
49
|
+
)}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Product search for a search page: a search box, Shopify's matching
|
|
4
|
+
* products with its filters, sort orders and paging, all from the page URL
|
|
5
|
+
* (?q=…), so the page must be rendered on demand. With <RedeemIDClient />
|
|
6
|
+
* the box also suggests products as the visitor types.
|
|
7
|
+
*
|
|
8
|
+
* <ProductSearch productHref="/shop/{handle}/" />
|
|
9
|
+
*
|
|
10
|
+
* `boxOnly` renders just the search box (for a header), sending searches to
|
|
11
|
+
* `action`, the search page's path.
|
|
12
|
+
*/
|
|
13
|
+
import { browseFromUrl, searchProducts, type ProductPage } from "../catalog.js";
|
|
14
|
+
import { siteConfig } from "../site-config.js";
|
|
15
|
+
import { storeContext } from "../storefront.js";
|
|
16
|
+
import { text } from "../text.js";
|
|
17
|
+
import BrowseControls from "./BrowseControls.astro";
|
|
18
|
+
import ProductGrid from "./ProductGrid.astro";
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
productHref?: string;
|
|
22
|
+
/** Products per page, 1 to 50. */
|
|
23
|
+
limit?: number;
|
|
24
|
+
/** Only the search box. */
|
|
25
|
+
boxOnly?: boolean;
|
|
26
|
+
/** Where searches go. Defaults to this page. */
|
|
27
|
+
action?: string;
|
|
28
|
+
label?: string;
|
|
29
|
+
quickAdd?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const config = await siteConfig(Astro.locals);
|
|
33
|
+
const { productHref, limit = 24, boxOnly = false, action = Astro.url.pathname, label = text(config, "Search products"), quickAdd = false } = Astro.props;
|
|
34
|
+
const browse = browseFromUrl(Astro.url.searchParams, limit);
|
|
35
|
+
let page: ProductPage | null = null;
|
|
36
|
+
let failed = false;
|
|
37
|
+
if (!boxOnly && config && browse.query) {
|
|
38
|
+
try {
|
|
39
|
+
page = await searchProducts(config, browse.query, browse, { context: storeContext(config, Astro.request) });
|
|
40
|
+
} catch {
|
|
41
|
+
failed = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const id = boxOnly ? "redeemid-search-box" : "redeemid-search";
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<section class="redeemid-search" role="search">
|
|
48
|
+
<form method="get" action={action} class="redeemid-search__form">
|
|
49
|
+
<label for={`${id}-q`}>{label}</label>
|
|
50
|
+
<input
|
|
51
|
+
id={`${id}-q`}
|
|
52
|
+
name="q"
|
|
53
|
+
type="search"
|
|
54
|
+
value={boxOnly ? "" : browse.query}
|
|
55
|
+
autocomplete="off"
|
|
56
|
+
maxlength="200"
|
|
57
|
+
data-redeemid-suggest
|
|
58
|
+
data-redeemid-product-href={productHref}
|
|
59
|
+
aria-controls={`${id}-suggestions`}
|
|
60
|
+
aria-expanded="false"
|
|
61
|
+
/>
|
|
62
|
+
<button type="submit">{text(config, "Search")}</button>
|
|
63
|
+
<ul id={`${id}-suggestions`} class="redeemid-search__suggestions" hidden></ul>
|
|
64
|
+
<p style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap" role="status" data-redeemid-suggest-status></p>
|
|
65
|
+
</form>
|
|
66
|
+
{!boxOnly && browse.query && (
|
|
67
|
+
failed ? (
|
|
68
|
+
<p>{text(config, "Search isn't available right now. Try again in a few minutes.")}</p>
|
|
69
|
+
) : page && (
|
|
70
|
+
<>
|
|
71
|
+
<p class="redeemid-search__count" role="status">
|
|
72
|
+
{page.products.length === 0
|
|
73
|
+
? text(config, "No products match “{query}”.", { query: browse.query })
|
|
74
|
+
: (page.total ?? page.products.length) === 1
|
|
75
|
+
? text(config, "1 result for “{query}”", { query: browse.query })
|
|
76
|
+
: text(config, "{count} results for “{query}”", { count: page.total ?? page.products.length, query: browse.query })}
|
|
77
|
+
</p>
|
|
78
|
+
{(page.products.length > 0 || page.filters.length > 0) && <BrowseControls filters={page.filters} pageInfo={page.pageInfo} search query={browse.query} id={id} />}
|
|
79
|
+
<ProductGrid products={page.products} productHref={productHref} heading="h2" quickAdd={quickAdd} />
|
|
80
|
+
</>
|
|
81
|
+
)
|
|
82
|
+
)}
|
|
83
|
+
</section>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Shopify's product recommendations under a product: "related" (You may also
|
|
4
|
+
* like) or "complementary" (Pair it with, set in the Search & Discovery
|
|
5
|
+
* app). Renders nothing when Shopify has none.
|
|
6
|
+
*
|
|
7
|
+
* <Recommendations handle="harbor-tote" productHref="/shop/{handle}/" />
|
|
8
|
+
*/
|
|
9
|
+
import { recommendations } from "../catalog.js";
|
|
10
|
+
import { siteConfig } from "../site-config.js";
|
|
11
|
+
import { product, storeContext, type ProductCard } from "../storefront.js";
|
|
12
|
+
import { text } from "../text.js";
|
|
13
|
+
import ProductGrid from "./ProductGrid.astro";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
handle: string;
|
|
17
|
+
intent?: "related" | "complementary";
|
|
18
|
+
/** Up to 10. */
|
|
19
|
+
limit?: number;
|
|
20
|
+
productHref?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
quickAdd?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config = await siteConfig(Astro.locals);
|
|
26
|
+
const { handle, intent = "related", limit = 4, productHref, quickAdd = false } = Astro.props;
|
|
27
|
+
const title = Astro.props.title ?? text(config, intent === "complementary" ? "Pair it with" : "You may also like");
|
|
28
|
+
let cards: ProductCard[] = [];
|
|
29
|
+
if (config) {
|
|
30
|
+
try {
|
|
31
|
+
const context = storeContext(config, Astro.request);
|
|
32
|
+
const p = await product(config, handle, undefined, context);
|
|
33
|
+
if (p?.id) cards = (await recommendations(config, p.id, intent, { context })).slice(0, Math.max(1, Math.min(10, limit)));
|
|
34
|
+
} catch {
|
|
35
|
+
cards = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
{cards.length > 0 && (
|
|
41
|
+
<section class="redeemid-recommendations" aria-labelledby={`redeemid-recs-${handle}`}>
|
|
42
|
+
<h2 id={`redeemid-recs-${handle}`}>{title}</h2>
|
|
43
|
+
<ProductGrid products={cards} productHref={productHref} quickAdd={quickAdd} />
|
|
44
|
+
</section>
|
|
45
|
+
)}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The browser client, window.redeemid. Put it once in the site layout
|
|
4
|
+
* (before </body>). See docs/developer-api.md for what it offers.
|
|
5
|
+
*
|
|
6
|
+
* <RedeemIDClient />
|
|
7
|
+
*/
|
|
8
|
+
import { API_VERSION } from "../api-version.js";
|
|
9
|
+
import { NOTICES } from "../notices.js";
|
|
10
|
+
import { siteConfig } from "../site-config.js";
|
|
11
|
+
import { text } from "../text.js";
|
|
12
|
+
import { VERSION } from "../version.js";
|
|
13
|
+
|
|
14
|
+
const config = await siteConfig(Astro.locals);
|
|
15
|
+
const boot = {
|
|
16
|
+
version: VERSION,
|
|
17
|
+
apiVersion: API_VERSION,
|
|
18
|
+
base: "/_emdash/api/auth/redeemid",
|
|
19
|
+
locale: "en-US",
|
|
20
|
+
notices: Object.fromEntries(Object.entries(NOTICES).map(([code, message]) => [code, text(config, message)])),
|
|
21
|
+
strings: {
|
|
22
|
+
"Your cart is empty.": text(config, "Your cart is empty."),
|
|
23
|
+
"Loading your cart…": text(config, "Loading your cart…"),
|
|
24
|
+
"{price} each · {total}": text(config, "{price} each · {total}"),
|
|
25
|
+
Quantity: text(config, "Quantity"),
|
|
26
|
+
Update: text(config, "Update"),
|
|
27
|
+
Remove: text(config, "Remove"),
|
|
28
|
+
"Discount code": text(config, "Discount code"),
|
|
29
|
+
Apply: text(config, "Apply"),
|
|
30
|
+
"{code} doesn't apply to this cart.": text(config, "{code} doesn't apply to this cart."),
|
|
31
|
+
Subtotal: text(config, "Subtotal"),
|
|
32
|
+
Total: text(config, "Total"),
|
|
33
|
+
"Taxes and shipping are worked out at checkout.": text(config, "Taxes and shipping are worked out at checkout."),
|
|
34
|
+
Checkout: text(config, "Checkout"),
|
|
35
|
+
"Sold out.": text(config, "Sold out."),
|
|
36
|
+
"Not available in this combination.": text(config, "Not available in this combination."),
|
|
37
|
+
"{count} suggestions": text(config, "{count} suggestions"),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<script type="application/json" id="redeemid-client-config" set:html={JSON.stringify(boot).replace(/</g, "\\u003c")} />
|
|
43
|
+
<script>
|
|
44
|
+
import "../client/redeemid.ts";
|
|
45
|
+
</script>
|