Skip to content

Commit 09d099d

Browse files
committed
initialize dev docs for loyalty extension
1 parent 366aacf commit 09d099d

File tree

9 files changed

+397
-0
lines changed

9 files changed

+397
-0
lines changed

.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module.exports = {
88
ignorePatterns: [
99
'build/',
1010
'examples/',
11+
'mdxExamples/',
1112
'node_modules/',
1213
'packages/*/build/',
1314
'packages/*/*.d.ts',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React, {useState} from 'react';
2+
3+
import {
4+
reactExtension,
5+
POSBlock,
6+
Text,
7+
POSBlockRow,
8+
useApi,
9+
Button,
10+
} from '@shopify/ui-extensions-react/point-of-sale';
11+
12+
import {useLoyaltyPoints} from './useLoyaltyPoints';
13+
import {applyDiscount} from './applyDiscount';
14+
15+
// For development purposes, we'll use a local server
16+
export const serverUrl =
17+
'https://hrs-macintosh-graph-testimonials.trycloudflare.com';
18+
// [START loyalty-points-block.discounts]
19+
// 1. Define discount tiers and available discounts
20+
const discountTiers = [
21+
{pointsRequired: 100, discountValue: 5},
22+
{pointsRequired: 200, discountValue: 10},
23+
{pointsRequired: 300, discountValue: 15},
24+
];
25+
// [END loyalty-points-block.discounts]
26+
const LoyaltyPointsBlock = () => {
27+
// [START loyalty-points-block.api]
28+
// 2. Initialize API
29+
const api = useApi<'pos.customer-details.block.render'>();
30+
const customerId = api.customer.id;
31+
const [pointsTotal, setPointsTotal] = useState<number | null>(null);
32+
// [END loyalty-points-block.api]
33+
34+
// [START loyalty-points-block.use-loyalty-points]
35+
// 3. Pass setPointsTotal to useLoyaltyPoints to calculate the points total
36+
const {loading} = useLoyaltyPoints(api, customerId, setPointsTotal);
37+
// [END loyalty-points-block.use-loyalty-points]
38+
39+
// [START loyalty-points-block.available-discounts]
40+
// 4. Filter available discounts based on points total
41+
const availableDiscounts = pointsTotal
42+
? discountTiers.filter((tier) => pointsTotal >= tier.pointsRequired)
43+
: [];
44+
// [END loyalty-points-block.available-discounts]
45+
if (loading) {
46+
return <Text>Loading...</Text>;
47+
}
48+
49+
if (pointsTotal === null) {
50+
return (
51+
<POSBlock>
52+
<POSBlockRow>
53+
<Text color="TextWarning">Unable to fetch points total.</Text>
54+
</POSBlockRow>
55+
</POSBlock>
56+
);
57+
}
58+
return (
59+
<POSBlock>
60+
<POSBlockRow>
61+
<Text variant="headingLarge" color="TextSuccess">
62+
{/* [START loyalty-points-block.display-points] */}
63+
{/* 5. Display the points total */}
64+
Point Balance:{pointsTotal}
65+
</Text>
66+
</POSBlockRow>
67+
{/* [END loyalty-points-block.display-points] */}
68+
{availableDiscounts.length > 0 ? (
69+
<POSBlockRow>
70+
<Text variant="headingSmall">Available Discounts:</Text>
71+
{/* [START loyalty-points-block.display-discounts] */}
72+
{/* 6. Display available discounts as buttons, calling applyDiscount */}
73+
{availableDiscounts.map((tier, index) => (
74+
<POSBlockRow key={`${tier.pointsRequired}-${index}`}>
75+
<Button
76+
title={`Redeem $${tier.discountValue} Discount (Use ${tier.pointsRequired} points)`}
77+
type="primary"
78+
onPress={() =>
79+
applyDiscount(
80+
api,
81+
customerId,
82+
tier.discountValue,
83+
tier.pointsRequired,
84+
setPointsTotal,
85+
)
86+
}
87+
/>
88+
</POSBlockRow>
89+
))}
90+
</POSBlockRow>
91+
) : (
92+
<POSBlockRow>
93+
<Text variant="headingSmall" color="TextWarning">
94+
No available discounts.
95+
</Text>
96+
</POSBlockRow>
97+
)}
98+
</POSBlock>
99+
);
100+
};
101+
// 7. Render the LoyaltyPointsBlock component at the appropriate target
102+
// [START loyalty-points-block.render]
103+
export default reactExtension('pos.customer-details.block.render', () => (
104+
<LoyaltyPointsBlock />
105+
));
106+
// [END loyalty-points-block.render]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {serverUrl} from './LoyaltyPointsBlock';
2+
import type {
3+
ApiForRenderExtension,
4+
RenderExtensionTarget,
5+
} from '@shopify/ui-extensions/point-of-sale';
6+
export const applyDiscount = async <Target extends RenderExtensionTarget>(
7+
api: ApiForRenderExtension<Target>,
8+
customerId: number,
9+
discountValue: number,
10+
pointsToDeduct: number,
11+
setPointsTotal: React.Dispatch<React.SetStateAction<number | null>>,
12+
) => {
13+
// [START apply-discount.cart]
14+
// 1. Apply discount to cart using the Cart API
15+
16+
api.cart.applyCartDiscount(
17+
'FixedAmount',
18+
'Loyalty Discount',
19+
discountValue.toString(),
20+
);
21+
// [END apply-discount.cart]
22+
const sessionToken = await api.session.getSessionToken();
23+
24+
// 2. Deduct points from server
25+
// [START apply-discount.deduct]
26+
const response = await fetch(`${serverUrl}/points/${customerId}/deduct`, {
27+
method: 'POST',
28+
headers: {
29+
Authorization: `Bearer ${sessionToken}`,
30+
'Content-Type': 'application/json',
31+
},
32+
body: JSON.stringify({pointsToDeduct}),
33+
});
34+
if (!response.ok) {
35+
const errorText = await response.text();
36+
throw new Error(`Failed to deduct points: ${errorText}`);
37+
return;
38+
}
39+
const {updatedPointsTotal} = await response.json();
40+
console.log('Updated points total:', updatedPointsTotal);
41+
// [END apply-discount.deduct]
42+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {authenticate} from 'app/shopify.server';
2+
3+
export async function fetchCustomerTotal(request: Request, customerId: string) {
4+
try {
5+
// [START fetch-customer.authenticate]
6+
// 1. Authenticate the request
7+
const {admin} = await authenticate.admin(request);
8+
// [END fetch-customer.authenticate]
9+
// [START fetch-customer.format]
10+
// 2. Format the customer ID
11+
const formattedCustomerId = `gid://shopify/Customer/${customerId}`;
12+
// [END fetch-customer.format]
13+
14+
// [START fetch-customer.orders]
15+
// 3. Fetch the customer's orders
16+
const response = await admin.graphql(
17+
`#graphql
18+
query GetCustomerOrders($customerId: ID!) {
19+
customer(id: $customerId) {
20+
orders(first: 100) {
21+
edges {
22+
node {
23+
id
24+
currentSubtotalPriceSet {
25+
shopMoney {
26+
amount
27+
currencyCode
28+
}
29+
}
30+
}
31+
}
32+
}
33+
}
34+
}`,
35+
{variables: {customerId: formattedCustomerId}},
36+
);
37+
// [END fetch-customer.orders]
38+
// [START fetch-customer.parse]
39+
// 4. Parse the response and handle erorrs
40+
const data = (await response.json()) as {data?: any; errors?: any[]};
41+
let grandTotal = 0;
42+
43+
if (data.errors) {
44+
console.error('GraphQL Errors:', data.errors);
45+
data.errors.forEach((error: any) => {
46+
console.error('GraphQL Error Details:', error);
47+
});
48+
return null;
49+
}
50+
51+
if (!response.ok) {
52+
console.error('Network Error:', response.statusText);
53+
return null;
54+
}
55+
56+
const orders = data.data.customer.orders;
57+
if (!orders) {
58+
console.error('No orders found for customer');
59+
return null;
60+
}
61+
// [END fetch-customer.parse]
62+
// [START fetch-customer.return]
63+
// 5. Calculate the grand total and return
64+
for (const edge of orders.edges) {
65+
const amountString = edge.node.currentSubtotalPriceSet.shopMoney.amount;
66+
if (amountString) {
67+
grandTotal += parseFloat(amountString);
68+
}
69+
}
70+
71+
return grandTotal;
72+
} catch (error) {
73+
console.error('Error fetching data:', error);
74+
return null;
75+
}
76+
}
77+
// [END fetch-customer.return]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type {ActionFunction} from '@remix-run/node';
2+
import {json} from '@remix-run/node';
3+
import {addRedeemedPoints} from '../services/redeemedPoints.server';
4+
import {authenticate} from 'app/shopify.server';
5+
// [START points.deduct.action]
6+
export const action: ActionFunction = async ({request, params}) => {
7+
if (request.method === 'OPTIONS') {
8+
return new Response(null, {
9+
status: 204,
10+
headers: {
11+
'Access-Control-Allow-Origin': '*',
12+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
13+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
14+
},
15+
});
16+
}
17+
18+
// 1. Authenticate the request
19+
try {
20+
await authenticate.admin(request);
21+
} catch (error) {
22+
console.error('Authentication failed:', error);
23+
return new Response('Unauthorized', {status: 401});
24+
}
25+
26+
// 2. Get the customer ID from the params
27+
const {customerId} = params;
28+
29+
const {pointsToDeduct} = await request.json();
30+
if (!customerId) {
31+
throw new Error('Customer ID is required');
32+
}
33+
34+
await addRedeemedPoints(customerId, pointsToDeduct);
35+
36+
return json({message: 'Points deducted successfully'});
37+
};
38+
// [END points.deduct.action]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type {LoaderFunctionArgs} from '@remix-run/node';
2+
import {authenticate} from '../shopify.server';
3+
import {json} from '@remix-run/node';
4+
import {fetchCustomerTotal} from './fetchCustomer';
5+
import {getRedeemedPoints} from '../services/redeemedPoints.server';
6+
// [START points.loader]
7+
export const loader = async ({request, params}: LoaderFunctionArgs) => {
8+
// 1. Authenticate the request
9+
await authenticate.admin(request);
10+
11+
// 2. Get the customer ID from the params
12+
const {customerId} = params;
13+
14+
if (!customerId) {
15+
throw new Response('Customer ID is required', {status: 400});
16+
}
17+
// 3. Fetch the customer total
18+
const data = await fetchCustomerTotal(request, customerId);
19+
20+
if (data === null) {
21+
throw new Response('Order not found', {status: 404});
22+
}
23+
24+
const totalRedeemedPoints = await getRedeemedPoints(customerId);
25+
26+
const totalPoints = data * 10 - totalRedeemedPoints;
27+
28+
return json(
29+
{totalPoints},
30+
{
31+
headers: {
32+
// Allow requests from all origins (or specify your client origin)
33+
'Access-Control-Allow-Origin': '*',
34+
// Allow specific headers if necessary
35+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
36+
},
37+
},
38+
);
39+
};
40+
// [END points.loader]
41+
export default null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import prisma from '../db.server';
2+
3+
// [START get-redeemed-points.query]
4+
export async function getRedeemedPoints(customerId: string): Promise<number> {
5+
const record = await prisma.redeemedPoints.findUnique({
6+
where: {customerId},
7+
});
8+
return record ? record.pointsRedeemed : 0;
9+
}
10+
// [END get-redeemed-points.query]
11+
12+
// [START add-redeemed-points.query]
13+
export async function addRedeemedPoints(
14+
customerId: string,
15+
points: number,
16+
): Promise<void> {
17+
await prisma.redeemedPoints.upsert({
18+
where: {customerId},
19+
update: {pointsRedeemed: {increment: points}},
20+
create: {customerId, pointsRedeemed: points},
21+
});
22+
}
23+
// [END add-redeemed-points.query]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# The version of APIs your extension will receive. Learn more:
2+
# https://shopify.dev/docs/api/usage/versioning
3+
api_version = "2024-10"
4+
5+
[[extensions]]
6+
type = "ui_extension"
7+
name = "loyalty-extension"
8+
9+
handle = "loyalty-extension"
10+
description = "Loyalty Points Extension"
11+
12+
# Controls where in POS your extension will be injected,
13+
# and the file that contains your extension’s source code.
14+
[[extensions.targeting]]
15+
module = "./src/LoyaltyPointsBlock.tsx"
16+
target = "pos.customer-details.block.render"
17+

0 commit comments

Comments
 (0)