-
Notifications
You must be signed in to change notification settings - Fork 35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
initialize dev docs for loyalty extension #2486
base: unstable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import React, {useState} from 'react'; | ||
|
||
import { | ||
reactExtension, | ||
POSBlock, | ||
Text, | ||
POSBlockRow, | ||
useApi, | ||
Button, | ||
} from '@shopify/ui-extensions-react/point-of-sale'; | ||
|
||
import {useLoyaltyPoints} from './useLoyaltyPoints'; | ||
import {applyDiscount} from './applyDiscount'; | ||
|
||
// 1. Define discount tiers and available discounts | ||
// For development purposes, we'll use a local server | ||
export const serverUrl = 'SERVER URL HERE'; | ||
|
||
const discountTiers = [ | ||
{pointsRequired: 100, discountValue: 5}, | ||
{pointsRequired: 200, discountValue: 10}, | ||
{pointsRequired: 300, discountValue: 15}, | ||
]; | ||
|
||
const LoyaltyPointsBlock = () => { | ||
// 2. Initialize API | ||
const api = useApi<'pos.customer-details.block.render'>(); | ||
const customerId = api.customer.id; | ||
const [pointBalance, setPointBalance] = useState<number | null>(null); | ||
|
||
// 3. Pass setPointBalance to useLoyaltyPoints to calculate the point balance | ||
const {loading} = useLoyaltyPoints(api, customerId, setPointBalance); | ||
|
||
// 4. Filter available discounts based on point balance | ||
const availableDiscounts = pointBalance | ||
? discountTiers.filter((tier) => pointBalance >= tier.pointsRequired) | ||
: []; | ||
|
||
if (loading) { | ||
return <Text>Loading...</Text>; | ||
} | ||
|
||
if (pointBalance === null) { | ||
return ( | ||
<POSBlock> | ||
<POSBlockRow> | ||
<Text color="TextWarning">Unable to fetch point balance.</Text> | ||
</POSBlockRow> | ||
</POSBlock> | ||
); | ||
} | ||
return ( | ||
<POSBlock> | ||
<POSBlockRow> | ||
<Text variant="headingLarge" color="TextSuccess"> | ||
{/* 5. Display the point balance */} | ||
Point Balance:{pointBalance} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this missing a space? |
||
</Text> | ||
</POSBlockRow> | ||
|
||
{availableDiscounts.length > 0 ? ( | ||
<> | ||
<POSBlockRow> | ||
<Text variant="headingSmall">Available Discounts:</Text> | ||
{/* 6. Display available discounts as buttons, calling applyDiscount */} | ||
{availableDiscounts.map((tier) => ( | ||
<POSBlockRow key={tier.pointsRequired}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you try a List component here? Just wondering if it works and if it looks better than stacking POSBlockRows |
||
<Button | ||
title={`Redeem $${tier.discountValue} Discount (Use ${tier.pointsRequired} points)`} | ||
type="primary" | ||
onPress={() => | ||
applyDiscount( | ||
api, | ||
customerId, | ||
tier.discountValue, | ||
tier.pointsRequired, | ||
setPointBalance, | ||
) | ||
} | ||
/> | ||
</POSBlockRow> | ||
))} | ||
</POSBlockRow> | ||
</> | ||
) : ( | ||
<POSBlockRow> | ||
<Text variant="headingSmall" color="TextWarning"> | ||
No available discounts. | ||
</Text> | ||
</POSBlockRow> | ||
)} | ||
</POSBlock> | ||
); | ||
}; | ||
// 7. Render the LoyaltyPointsBlock component at the appropriate target | ||
export default reactExtension('pos.customer-details.block.render', () => ( | ||
<LoyaltyPointsBlock /> | ||
)); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import type {useApi} from '@shopify/ui-extensions-react/point-of-sale'; | ||
import {serverUrl} from './LoyaltyPointsBlock'; | ||
export const applyDiscount = async ( | ||
api: ReturnType<typeof useApi>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does |
||
customerId: number, | ||
discountValue: number, | ||
pointsToDeduct: number, | ||
setPointBalance: React.Dispatch<React.SetStateAction<number | null>>, | ||
) => { | ||
// 1. Apply discount to cart using the Cart API | ||
try { | ||
api.cart.applyCartDiscount( | ||
'FixedAmount', | ||
'Loyalty Discount', | ||
discountValue.toString(), | ||
); | ||
|
||
const sessionToken = await api.session.getSessionToken(); | ||
|
||
// 2. Deduct points from the backend | ||
const response = await fetch(`${serverUrl}/points/${customerId}/deduct`, { | ||
method: 'POST', | ||
headers: { | ||
Authorization: `Bearer ${sessionToken}`, | ||
'Content-Type': 'application/json', | ||
}, | ||
body: JSON.stringify({pointsToDeduct}), | ||
}); | ||
if (!response.ok) { | ||
const errorText = await response.text(); | ||
throw new Error(`Failed to deduct points: ${errorText}`); | ||
} | ||
// 3. Update the point balance in the state | ||
setPointBalance((prev) => (prev !== null ? prev - pointsToDeduct : null)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's good practice to let the server respond with the updated total here, so the math isn't being done in two places. |
||
|
||
console.log('Points deducted successfully'); | ||
} catch (error) { | ||
console.error('Error deducting points:', error); | ||
} | ||
Comment on lines
+37
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a little weird to throw and catch in the same function. You should prefer an early return instead, so I'd log the failure on line 31 and then just |
||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
shopify upgrade | ||
shopify app generate extension | ||
Comment on lines
+1
to
+2
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can leave this out |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import type {ActionFunction} from '@remix-run/node'; | ||
import {json} from '@remix-run/node'; | ||
import {addRedeemedPoints} from '../services/redeemedPoints.server'; | ||
import {authenticate} from 'app/shopify.server'; | ||
|
||
export const action: ActionFunction = async ({request, params}) => { | ||
if (request.method === 'OPTIONS') { | ||
return new Response(null, { | ||
status: 204, | ||
headers: { | ||
'Access-Control-Allow-Origin': '*', | ||
'Access-Control-Allow-Headers': 'Authorization, Content-Type', | ||
'Access-Control-Allow-Methods': 'POST, OPTIONS', | ||
}, | ||
}); | ||
} | ||
|
||
// 1. Authenticate the request | ||
try { | ||
await authenticate.admin(request); | ||
} catch (error) { | ||
console.error('Authentication failed:', error); | ||
return new Response('Unauthorized', {status: 401}); | ||
} | ||
Comment on lines
+18
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you let this error propagate, doesn't it result in a 401? In other words, what happens if you don't catch this error? |
||
|
||
// 2. Get the customer ID from the params | ||
const {customerId} = params; | ||
|
||
const {pointsToDeduct} = await request.json(); | ||
if (!customerId) { | ||
throw new Error('Customer ID is required'); | ||
} | ||
|
||
await addRedeemedPoints(customerId, pointsToDeduct); | ||
|
||
return json({message: 'Points deducted successfully'}); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import type {LoaderFunctionArgs} from '@remix-run/node'; | ||
import {authenticate} from '../shopify.server'; | ||
import {json} from '@remix-run/node'; | ||
import {fetchCustomerTotal} from './fetchCustomer'; | ||
import {getRedeemedPoints} from '../services/redeemedPoints.server'; | ||
|
||
export const loader = async ({request, params}: LoaderFunctionArgs) => { | ||
// 1. Authenticate the request | ||
await authenticate.admin(request); | ||
|
||
// 2. Get the customer ID from the params | ||
const {customerId} = params; | ||
|
||
if (!customerId) { | ||
throw new Response('Customer ID is required', {status: 400}); | ||
} | ||
// 3. Fetch the customer total | ||
const data = await fetchCustomerTotal(request, customerId); | ||
|
||
if (data === null) { | ||
throw new Response('Order not found', {status: 404}); | ||
} | ||
|
||
// 4. Fetch the redeemed points | ||
const totalRedeemedPoints = await getRedeemedPoints(customerId); | ||
|
||
// 5. Convert the customer total to points, subtracting the redeemed points | ||
const pointBalance = data * 10 - totalRedeemedPoints; | ||
|
||
return json( | ||
{pointBalance}, | ||
{ | ||
headers: { | ||
'Access-Control-Allow-Origin': '*', | ||
'Access-Control-Allow-Headers': 'Authorization, Content-Type', | ||
}, | ||
}, | ||
); | ||
}; | ||
|
||
export default null; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import prisma from '../db.server'; | ||
|
||
export async function getRedeemedPoints(customerId: string): Promise<number> { | ||
const record = await prisma.redeemedPoints.findUnique({ | ||
where: {customerId}, | ||
}); | ||
return record ? record.pointsRedeemed : 0; | ||
} | ||
|
||
export async function addRedeemedPoints( | ||
customerId: string, | ||
points: number, | ||
): Promise<void> { | ||
await prisma.redeemedPoints.upsert({ | ||
where: {customerId}, | ||
update: {pointsRedeemed: {increment: points}}, | ||
create: {customerId, pointsRedeemed: points}, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import {authenticate} from 'app/shopify.server'; | ||
|
||
export async function fetchCustomerTotal(request: Request, customerId: string) { | ||
try { | ||
// 1. Authenticate the request | ||
const {admin} = await authenticate.admin(request); | ||
|
||
// 2. Format the customer ID | ||
const formattedCustomerId = `gid://shopify/Customer/${customerId}`; | ||
|
||
// 3. Fetch the customer's orders | ||
const response = await admin.graphql( | ||
`#graphql | ||
query GetCustomerOrders($customerId: ID!) { | ||
customer(id: $customerId) { | ||
orders(first: 100) { | ||
edges { | ||
node { | ||
id | ||
currentSubtotalPriceSet { | ||
shopMoney { | ||
amount | ||
currencyCode | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
}`, | ||
{variables: {customerId: formattedCustomerId}}, | ||
); | ||
// 4. Parse the response and handle erorrs | ||
const data = (await response.json()) as {data?: any; errors?: any[]}; | ||
let grandTotal = 0; | ||
|
||
if (data.errors) { | ||
console.error('GraphQL Errors:', data.errors); | ||
data.errors.forEach((error: any) => { | ||
console.error('GraphQL Error Details:', error); | ||
}); | ||
return null; | ||
} | ||
|
||
if (!response.ok) { | ||
console.error('Network Error:', response.statusText); | ||
return null; | ||
} | ||
|
||
const orders = data.data.customer.orders; | ||
if (!orders) { | ||
console.error('No orders found for customer'); | ||
return null; | ||
} | ||
// 5. Calculate the grand total and return | ||
for (const edge of orders.edges) { | ||
const amountString = edge.node.currentSubtotalPriceSet.shopMoney.amount; | ||
if (amountString) { | ||
grandTotal += parseFloat(amountString); | ||
} | ||
} | ||
|
||
return grandTotal; | ||
} catch (error) { | ||
console.error('Error fetching data:', error); | ||
return null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
api_version = "2024-10" | ||
|
||
[[extensions]] | ||
type = "ui_extension" | ||
name = "loyalty-extension" | ||
|
||
handle = "loyalty-extension" | ||
description = "An example loyalty extension" | ||
|
||
[[extensions.targeting]] | ||
module = "./src/LoyaltyPointsBlock.tsx" | ||
target = "pos.customer-details.block.render" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import {useEffect, useState} from 'react'; | ||
import {serverUrl} from './LoyaltyPointsBlock'; | ||
|
||
export const useLoyaltyPoints = ( | ||
api: any, | ||
customerId: number, | ||
setPointBalance: React.Dispatch<React.SetStateAction<number | null>>, | ||
) => { | ||
const [loading, setLoading] = useState(true); | ||
|
||
// 1. Fetch the points total from the backend | ||
useEffect(() => { | ||
const fetchOrderData = async () => { | ||
try { | ||
// Get the session token | ||
const sessionToken = await api.session.getSessionToken(); | ||
|
||
const response = await fetch(`${serverUrl}/points/${customerId}`, { | ||
method: 'GET', | ||
headers: { | ||
Authorization: `Bearer ${sessionToken}`, | ||
}, | ||
}); | ||
|
||
if (!response.ok) { | ||
const errorText = await response.text(); | ||
console.error('Error Response Text:', errorText); | ||
throw new Error(`Failed to fetch order data: ${errorText}`); | ||
} | ||
|
||
const data = await response.json(); | ||
|
||
if (typeof data.totalPoints === 'number') { | ||
// 2. Update the points total in the state | ||
setPointBalance(data.totalPoints); | ||
} else { | ||
console.error('No points available in the response.'); | ||
} | ||
} catch (error) { | ||
console.error('Error fetching order data in client:', error); | ||
} finally { | ||
setLoading(false); | ||
} | ||
}; | ||
|
||
fetchOrderData(); | ||
}, [api, customerId, setPointBalance]); | ||
|
||
return {loading}; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does this end up looking like in a Block extension? Does it even work? I think you may need to wrap this in a POSBlock at least, and perhaps a POSBlockRow, too?