@pradip1995/segment-free-shipping-nudge 0.2.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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pradip1995/segment-free-shipping-nudge",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "src/segment.css"
10
+ ],
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./manifest": "./src/manifest.ts"
17
+ },
18
+ "peerDependencies": {
19
+ "@medusajs/types": "^2.0.0",
20
+ "@pradip1995/plugin-sdk": "^0.2.0",
21
+ "react": ">=19",
22
+ "react-dom": ">=19",
23
+ "next": ">=15"
24
+ },
25
+ "dependencies": {
26
+ "@pradip1995/segment-primitives": "0.3.0",
27
+ "@pradip1995/segment-tokens": "0.3.2"
28
+ },
29
+ "devDependencies": {
30
+ "@medusajs/types": "^2.0.0",
31
+ "@pradip1995/plugin-sdk": "^0.2.0",
32
+ "@types/react": "^19",
33
+ "react": "19.0.3",
34
+ "typescript": "^5.7.2"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "lint": "tsc --noEmit"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
@@ -0,0 +1,11 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "free-shipping-nudge",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ dataKey: "cartPage",
9
+ }
10
+
11
+ export default manifest
@@ -0,0 +1,79 @@
1
+ .free-shipping-nudge--inline {
2
+ background: var(--color-surface-muted);
3
+ border: 1px solid var(--color-border);
4
+ border-radius: 0.75rem;
5
+ padding: 0.75rem;
6
+ }
7
+
8
+ .free-shipping-nudge__row {
9
+ display: flex;
10
+ justify-content: space-between;
11
+ gap: 1rem;
12
+ font-size: 0.75rem;
13
+ color: var(--color-text-muted);
14
+ margin-bottom: 0.5rem;
15
+ }
16
+
17
+ .free-shipping-nudge__track {
18
+ display: flex;
19
+ gap: 0.25rem;
20
+ }
21
+
22
+ .free-shipping-nudge__fill {
23
+ height: 0.25rem;
24
+ border-radius: 9999px;
25
+ background: linear-gradient(to right, #a1a1aa, #71717a);
26
+ transition: width 0.5s ease;
27
+ }
28
+
29
+ .free-shipping-nudge__fill--complete {
30
+ background: linear-gradient(to right, #4ade80, #22c55e);
31
+ }
32
+
33
+ .free-shipping-nudge--popup {
34
+ position: fixed;
35
+ right: 1.25rem;
36
+ bottom: 1.25rem;
37
+ z-index: 40;
38
+ max-width: 24rem;
39
+ }
40
+
41
+ .free-shipping-nudge__close {
42
+ align-self: flex-end;
43
+ width: 2rem;
44
+ height: 2rem;
45
+ border-radius: 9999px;
46
+ background: var(--color-text-heading);
47
+ color: var(--color-text-inverse);
48
+ border: none;
49
+ margin-bottom: 0.5rem;
50
+ }
51
+
52
+ .free-shipping-nudge__popup-card {
53
+ background: var(--color-text-heading);
54
+ color: var(--color-text-inverse);
55
+ border-radius: 0.75rem;
56
+ padding: 1.25rem;
57
+ }
58
+
59
+ .free-shipping-nudge__actions {
60
+ display: flex;
61
+ gap: 0.75rem;
62
+ margin-top: 1rem;
63
+ }
64
+
65
+ .free-shipping-nudge__action {
66
+ flex: 1;
67
+ text-align: center;
68
+ border-radius: 1rem;
69
+ padding: 0.625rem 1rem;
70
+ background: var(--color-surface);
71
+ color: var(--color-text-heading);
72
+ text-decoration: none;
73
+ }
74
+
75
+ .free-shipping-nudge__action--ghost {
76
+ background: transparent;
77
+ color: var(--color-text-inverse);
78
+ border: 1px solid var(--color-text-inverse);
79
+ }
@@ -0,0 +1,178 @@
1
+ "use client"
2
+
3
+ import "./segment.css"
4
+ import { useState } from "react"
5
+ import type { HttpTypes } from "@medusajs/types"
6
+ import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
7
+ import { formatPrice } from "@pradip1995/segment-primitives/format-price"
8
+
9
+ type FreeShippingPrice = HttpTypes.StorePrice & {
10
+ shipping_option_id?: string
11
+ target_reached: boolean
12
+ target_remaining: number
13
+ remaining_percentage: number
14
+ }
15
+
16
+ function computeTarget(cart: HttpTypes.StoreCart, price: HttpTypes.StorePrice) {
17
+ const priceRule = (price.price_rules || []).find((pr) => pr.attribute === "item_total")
18
+ if (!priceRule) {
19
+ return {
20
+ target_reached: false,
21
+ target_remaining: 0,
22
+ remaining_percentage: 0,
23
+ }
24
+ }
25
+
26
+ const currentAmount = cart.item_total ?? 0
27
+ const targetAmount = parseFloat(priceRule.value)
28
+
29
+ if (priceRule.operator === "gt") {
30
+ return {
31
+ target_reached: currentAmount > targetAmount,
32
+ target_remaining: currentAmount > targetAmount ? 0 : targetAmount + 1 - currentAmount,
33
+ remaining_percentage: Math.min(100, (currentAmount / targetAmount) * 100),
34
+ }
35
+ }
36
+
37
+ if (priceRule.operator === "gte") {
38
+ return {
39
+ target_reached: currentAmount >= targetAmount,
40
+ target_remaining: currentAmount >= targetAmount ? 0 : targetAmount - currentAmount,
41
+ remaining_percentage: Math.min(100, (currentAmount / targetAmount) * 100),
42
+ }
43
+ }
44
+
45
+ return {
46
+ target_reached: currentAmount === targetAmount,
47
+ target_remaining: targetAmount > currentAmount ? targetAmount - currentAmount : 0,
48
+ remaining_percentage: Math.min(100, (currentAmount / targetAmount) * 100),
49
+ }
50
+ }
51
+
52
+ function resolveFreeShippingPrice(
53
+ cart: HttpTypes.StoreCart,
54
+ shippingOptions: HttpTypes.StoreCartShippingOption[]
55
+ ): FreeShippingPrice | null {
56
+ const prices = shippingOptions
57
+ .flatMap((shippingOption) => {
58
+ const validCurrencyPrices = shippingOption.prices.filter(
59
+ (price) =>
60
+ price.currency_code === cart.currency_code &&
61
+ (price.price_rules || []).some((rule) => rule.attribute === "item_total")
62
+ )
63
+
64
+ return validCurrencyPrices.map((price) => ({
65
+ ...price,
66
+ shipping_option_id: shippingOption.id,
67
+ ...computeTarget(cart, price),
68
+ }))
69
+ })
70
+ .filter((price) => price.amount === 0)
71
+
72
+ return (prices[0] as FreeShippingPrice | undefined) ?? null
73
+ }
74
+
75
+ function FreeShippingInline({
76
+ cart,
77
+ price,
78
+ }: {
79
+ cart: HttpTypes.StoreCart
80
+ price: FreeShippingPrice
81
+ }) {
82
+ return (
83
+ <div className="free-shipping-nudge free-shipping-nudge--inline">
84
+ <div className="free-shipping-nudge__row">
85
+ <span className="free-shipping-nudge__label">
86
+ {price.target_reached ? "Free shipping unlocked!" : "Unlock free shipping"}
87
+ </span>
88
+ {!price.target_reached ? (
89
+ <span className="free-shipping-nudge__remaining">
90
+ Only{" "}
91
+ <strong>
92
+ {formatPrice(price.target_remaining, cart.currency_code)}
93
+ </strong>{" "}
94
+ away
95
+ </span>
96
+ ) : null}
97
+ </div>
98
+ <div className="free-shipping-nudge__track" aria-hidden>
99
+ <div
100
+ className={`free-shipping-nudge__fill${price.target_reached ? " free-shipping-nudge__fill--complete" : ""}`}
101
+ style={{ width: `${price.remaining_percentage}%` }}
102
+ />
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ function FreeShippingPopup({
109
+ cart,
110
+ price,
111
+ }: {
112
+ cart: HttpTypes.StoreCart
113
+ price: FreeShippingPrice
114
+ }) {
115
+ const [isClosed, setIsClosed] = useState(false)
116
+
117
+ if (price.target_reached || isClosed) return null
118
+
119
+ return (
120
+ <div className="free-shipping-nudge free-shipping-nudge--popup">
121
+ <button
122
+ type="button"
123
+ className="free-shipping-nudge__close"
124
+ onClick={() => setIsClosed(true)}
125
+ aria-label="Dismiss"
126
+ >
127
+ ×
128
+ </button>
129
+ <div className="free-shipping-nudge__popup-card">
130
+ <div className="free-shipping-nudge__row">
131
+ <span className="free-shipping-nudge__label">Unlock free shipping</span>
132
+ <span className="free-shipping-nudge__remaining">
133
+ Only{" "}
134
+ <strong>
135
+ {formatPrice(price.target_remaining, cart.currency_code)}
136
+ </strong>{" "}
137
+ away
138
+ </span>
139
+ </div>
140
+ <div className="free-shipping-nudge__track" aria-hidden>
141
+ <div
142
+ className="free-shipping-nudge__fill"
143
+ style={{ width: `${price.remaining_percentage}%` }}
144
+ />
145
+ </div>
146
+ <div className="free-shipping-nudge__actions">
147
+ <LocalizedLink href="/cart" className="free-shipping-nudge__action free-shipping-nudge__action--ghost">
148
+ View cart
149
+ </LocalizedLink>
150
+ <LocalizedLink href="/store" className="free-shipping-nudge__action">
151
+ View products
152
+ </LocalizedLink>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ )
157
+ }
158
+
159
+ export default function FreeShippingNudge({
160
+ cart,
161
+ shippingOptions = [],
162
+ variant = "inline",
163
+ }: {
164
+ cart?: HttpTypes.StoreCart | null
165
+ shippingOptions?: HttpTypes.StoreCartShippingOption[]
166
+ variant?: "popup" | "inline"
167
+ }) {
168
+ if (!cart || shippingOptions.length === 0) return null
169
+
170
+ const price = resolveFreeShippingPrice(cart, shippingOptions)
171
+ if (!price) return null
172
+
173
+ if (variant === "popup") {
174
+ return <FreeShippingPopup cart={cart} price={price} />
175
+ }
176
+
177
+ return <FreeShippingInline cart={cart} price={price} />
178
+ }