From acfe6cc35064e8301aaa3773c1708a996d1d87e3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 13:29:47 -0700 Subject: [PATCH] feat: add Prodigi POD client + server-side product mockup previews - Prodigi v4 API client for order fulfillment (create/get orders, quotes) - Server-side mockup generation: Pillow composites designs onto product templates (shirt, sticker, print) at GET /api/designs/{slug}/mockup - Product listing and detail pages now show designs ON products - Mockup type switcher on product detail page (T-Shirt/Sticker/Art Print) - Order service submits to Prodigi after successful payment - POD webhook endpoint for fulfillment status updates Co-Authored-By: Claude Opus 4.6 --- backend/app/api/designs.py | 96 ++++++++++- backend/app/pod/__init__.py | 0 backend/app/pod/prodigi_client.py | 129 +++++++++++++++ backend/app/services/order_service.py | 72 +++++++- docker-compose.dev.yml | 1 + docker-compose.yml | 1 + frontend/app/products/[slug]/page.tsx | 165 ++++++++----------- frontend/app/products/page.tsx | 10 +- frontend/public/mockups/print-template.png | Bin 5450 -> 4379 bytes frontend/public/mockups/shirt-template.png | Bin 5917 -> 4852 bytes frontend/public/mockups/sticker-template.png | Bin 4905 -> 5864 bytes 11 files changed, 372 insertions(+), 102 deletions(-) create mode 100644 backend/app/pod/__init__.py create mode 100644 backend/app/pod/prodigi_client.py diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py index 49e77ee..b3f421e 100644 --- a/backend/app/api/designs.py +++ b/backend/app/api/designs.py @@ -1,15 +1,38 @@ """Designs API endpoints.""" +import io from pathlib import Path from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse +from PIL import Image +from app.config import get_settings from app.schemas.design import Design from app.services.design_service import DesignService router = APIRouter() design_service = DesignService() +settings = get_settings() + +# Mockup template configs: product_type → (template path, design bounding box) +MOCKUP_TEMPLATES = { + "shirt": { + "template": "shirt-template.png", + "design_box": (275, 300, 250, 250), # x, y, w, h on 800x800 canvas + }, + "sticker": { + "template": "sticker-template.png", + "design_box": (130, 130, 540, 540), + }, + "print": { + "template": "print-template.png", + "design_box": (160, 160, 480, 480), + }, +} + +# Cache generated mockups in memory: (slug, product_type) → PNG bytes +_mockup_cache: dict[tuple[str, str], bytes] = {} @router.get("", response_model=list[Design]) @@ -46,3 +69,74 @@ async def get_design_image(slug: str): "Cache-Control": "public, max-age=86400", # Cache for 24 hours }, ) + + +@router.get("/{slug}/mockup") +async def get_design_mockup(slug: str, type: str = "shirt"): + """Serve the design composited onto a product mockup template. + + Composites the design image onto a product template (shirt, sticker, print) + using Pillow. Result is cached in memory for fast subsequent requests. + + Query params: + type: Product type — "shirt", "sticker", or "print" (default: shirt) + """ + cache_key = (slug, type) + if cache_key in _mockup_cache: + return StreamingResponse( + io.BytesIO(_mockup_cache[cache_key]), + media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + template_config = MOCKUP_TEMPLATES.get(type) + if not template_config: + raise HTTPException(status_code=400, detail=f"Unknown product type: {type}") + + # Load design image + image_path = await design_service.get_design_image_path(slug) + if not image_path or not Path(image_path).exists(): + raise HTTPException(status_code=404, detail="Design image not found") + + # Load template image from frontend/public/mockups/ + template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups" + template_path = template_dir / template_config["template"] + + # Fallback: check if templates are mounted at /app/frontend/public/mockups/ + if not template_path.exists(): + template_path = Path("/app/mockups") / template_config["template"] + if not template_path.exists(): + raise HTTPException(status_code=404, detail="Mockup template not found") + + # Composite design onto product template + canvas = Image.new("RGBA", (800, 800), (0, 0, 0, 0)) + design_img = Image.open(image_path).convert("RGBA") + template_img = Image.open(str(template_path)).convert("RGBA") + + # Scale design to fit bounding box while maintaining aspect ratio + bx, by, bw, bh = template_config["design_box"] + scale = min(bw / design_img.width, bh / design_img.height) + dw = int(design_img.width * scale) + dh = int(design_img.height * scale) + dx = bx + (bw - dw) // 2 + dy = by + (bh - dh) // 2 + + design_resized = design_img.resize((dw, dh), Image.LANCZOS) + + # Draw design first (underneath), then template on top + canvas.paste(design_resized, (dx, dy), design_resized) + canvas.paste(template_img, (0, 0), template_img) + + # Export to PNG bytes + buf = io.BytesIO() + canvas.save(buf, format="PNG", optimize=True) + png_bytes = buf.getvalue() + + # Cache the result + _mockup_cache[cache_key] = png_bytes + + return StreamingResponse( + io.BytesIO(png_bytes), + media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}, + ) diff --git a/backend/app/pod/__init__.py b/backend/app/pod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/pod/prodigi_client.py b/backend/app/pod/prodigi_client.py new file mode 100644 index 0000000..46ad01a --- /dev/null +++ b/backend/app/pod/prodigi_client.py @@ -0,0 +1,129 @@ +"""Prodigi Print-on-Demand API client (v4). + +Handles order submission, product specs, and quotes. +Sandbox: https://api.sandbox.prodigi.com/v4.0/ +Production: https://api.prodigi.com/v4.0/ +""" + +import logging + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +SANDBOX_URL = "https://api.sandbox.prodigi.com/v4.0" +PRODUCTION_URL = "https://api.prodigi.com/v4.0" + + +class ProdigiClient: + """Client for the Prodigi v4 Print API.""" + + def __init__(self): + self.api_key = settings.prodigi_api_key + self.base_url = SANDBOX_URL if settings.pod_sandbox_mode else PRODUCTION_URL + self.enabled = bool(self.api_key) + + @property + def _headers(self) -> dict: + return { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + + async def create_order( + self, + items: list[dict], + recipient: dict, + shipping_method: str = "Budget", + metadata: dict | None = None, + ) -> dict: + """Create a Prodigi print order. + + Args: + items: List of items, each with: + - sku: Prodigi SKU (e.g., "GLOBAL-STI-KIS-4X4") + - copies: Number of copies + - sizing: "fillPrintArea" | "fitPrintArea" | "stretchToPrintArea" + - assets: [{"printArea": "default", "url": "https://..."}] + recipient: Shipping address with: + - name: Recipient name + - email: Email (optional) + - address: {line1, line2, townOrCity, stateOrCounty, postalOrZipCode, countryCode} + shipping_method: "Budget" | "Standard" | "Express" + metadata: Optional key/value metadata + """ + if not self.enabled: + raise ValueError("Prodigi API key not configured") + + payload = { + "shippingMethod": shipping_method, + "recipient": recipient, + "items": items, + } + if metadata: + payload["metadata"] = metadata + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{self.base_url}/Orders", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + result = resp.json() + logger.info(f"Prodigi order created: {result.get('id')}") + return result + + async def get_order(self, order_id: str) -> dict: + """Get order details by Prodigi order ID.""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{self.base_url}/Orders/{order_id}", + headers=self._headers, + ) + resp.raise_for_status() + return resp.json() + + async def get_product(self, sku: str) -> dict: + """Get product specifications (dimensions, print areas, etc.).""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{self.base_url}/products/{sku}", + headers=self._headers, + ) + resp.raise_for_status() + return resp.json() + + async def get_quote( + self, + items: list[dict], + shipping_method: str = "Budget", + destination_country: str = "US", + ) -> dict: + """Get a pricing quote before ordering. + + Args: + items: List with sku, copies, sizing, assets + shipping_method: Shipping tier + destination_country: 2-letter country code + """ + payload = { + "shippingMethod": shipping_method, + "destinationCountryCode": destination_country, + "items": [ + {"sku": item["sku"], "copies": item.get("copies", 1)} + for item in items + ], + } + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + f"{self.base_url}/quotes", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + return resp.json() diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 090f98a..dfd2a40 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -14,6 +14,7 @@ from app.models.customer import Customer from app.models.cart import Cart from app.schemas.order import OrderResponse, OrderItemResponse from app.services.flow_service import FlowService +from app.pod.prodigi_client import ProdigiClient logger = logging.getLogger(__name__) settings = get_settings() @@ -151,7 +152,9 @@ class OrderService: # Route revenue margin to TBFF flow → bonding curve await self._deposit_revenue_to_flow(order) - # TODO: Submit to POD providers + # Submit to POD providers + await self._submit_to_pod(order) + # TODO: Send confirmation email async def update_pod_status( @@ -177,6 +180,73 @@ class OrderService: ) await self.db.commit() + async def _submit_to_pod(self, order: Order): + """Submit order items to Prodigi for fulfillment. + + Groups items by POD provider and submits orders. + Design images are served via public URL for Prodigi to download. + """ + prodigi = ProdigiClient() + if not prodigi.enabled: + logger.info("Prodigi not configured, skipping POD submission") + return + + # Need shipping address for POD — skip if not available + if not order.shipping_address_line1: + logger.info(f"Order {order.id} has no shipping address, skipping POD") + return + + # Collect Prodigi items from order + prodigi_items = [] + for item in order.items: + # Build public image URL for Prodigi to download + # TODO: Use CDN URL in production; for now use the API endpoint + image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image" + + prodigi_items.append({ + "sku": item.variant or item.product_slug, + "copies": item.quantity, + "sizing": "fillPrintArea", + "assets": [{"printArea": "default", "url": image_url}], + }) + + if not prodigi_items: + return + + recipient = { + "name": order.shipping_name or "", + "email": order.shipping_email or "", + "address": { + "line1": order.shipping_address_line1 or "", + "line2": order.shipping_address_line2 or "", + "townOrCity": order.shipping_city or "", + "stateOrCounty": order.shipping_state or "", + "postalOrZipCode": order.shipping_postal_code or "", + "countryCode": order.shipping_country or "", + }, + } + + try: + result = await prodigi.create_order( + items=prodigi_items, + recipient=recipient, + metadata={"rswag_order_id": str(order.id)}, + ) + pod_order_id = result.get("id") + + # Update order items with Prodigi order ID + for item in order.items: + item.pod_provider = "prodigi" + item.pod_order_id = pod_order_id + item.pod_status = "submitted" + + order.status = OrderStatus.PROCESSING.value + await self.db.commit() + logger.info(f"Submitted order {order.id} to Prodigi: {pod_order_id}") + + except Exception as e: + logger.error(f"Failed to submit order {order.id} to Prodigi: {e}") + async def _deposit_revenue_to_flow(self, order: Order): """Calculate margin and deposit to TBFF flow for bonding curve funding. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1c53a5e..839933f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,6 +17,7 @@ services: - ./designs:/app/designs:ro - ./config:/app/config:ro - ./spaces:/app/spaces:ro + - ./frontend/public/mockups:/app/mockups:ro environment: - DEBUG=true - POD_SANDBOX_MODE=true diff --git a/docker-compose.yml b/docker-compose.yml index c2e8e4f..fabba59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: - ./designs:/app/designs - ./config:/app/config:ro - ./spaces:/app/spaces:ro + - ./frontend/public/mockups:/app/mockups:ro depends_on: db: condition: service_healthy diff --git a/frontend/app/products/[slug]/page.tsx b/frontend/app/products/[slug]/page.tsx index e86734b..93f9b9b 100644 --- a/frontend/app/products/[slug]/page.tsx +++ b/frontend/app/products/[slug]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import Link from "next/link"; import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces"; @@ -27,15 +27,28 @@ interface Product { is_active: boolean; } +const MOCKUP_TYPES = [ + { type: "shirt", label: "T-Shirt" }, + { type: "sticker", label: "Sticker" }, + { type: "print", label: "Art Print" }, +]; + +function getMockupType(productType: string): string { + if (productType.includes("shirt") || productType.includes("tee") || productType.includes("hoodie")) return "shirt"; + if (productType.includes("sticker")) return "sticker"; + if (productType.includes("print")) return "print"; + return "shirt"; +} + export default function ProductPage() { const params = useParams(); - const router = useRouter(); const slug = params.slug as string; const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedMockup, setSelectedMockup] = useState("shirt"); const [quantity, setQuantity] = useState(1); const [addingToCart, setAddingToCart] = useState(false); const [addedToCart, setAddedToCart] = useState(false); @@ -45,47 +58,32 @@ export default function ProductPage() { try { const res = await fetch(`${API_URL}/products/${slug}`); if (!res.ok) { - if (res.status === 404) { - setError("Product not found"); - } else { - setError("Failed to load product"); - } + setError(res.status === 404 ? "Product not found" : "Failed to load product"); return; } const data = await res.json(); setProduct(data); - if (data.variants && data.variants.length > 0) { + if (data.variants?.length > 0) { setSelectedVariant(data.variants[0]); } + setSelectedMockup(getMockupType(data.product_type)); } catch { setError("Failed to load product"); } finally { setLoading(false); } } - - if (slug) { - fetchProduct(); - } + if (slug) fetchProduct(); }, [slug]); const getOrCreateCart = async (): Promise => { - // Check for existing cart in localStorage let cartId = localStorage.getItem(getCartKey(getSpaceIdFromCookie())); - if (cartId) { - // Verify cart still exists try { const res = await fetch(`${API_URL}/cart/${cartId}`); - if (res.ok) { - return cartId; - } - } catch { - // Cart doesn't exist, create new one - } + if (res.ok) return cartId; + } catch { /* cart expired */ } } - - // Create new cart try { const res = await fetch(`${API_URL}/cart`, { method: "POST", @@ -97,33 +95,24 @@ export default function ProductPage() { localStorage.setItem(getCartKey(getSpaceIdFromCookie()), cartId!); return cartId; } - } catch { - return null; - } - + } catch { return null; } return null; }; const handleAddToCart = async () => { if (!product || !selectedVariant) return; - setAddingToCart(true); try { const cartId = await getOrCreateCart(); - if (!cartId) { - alert("Failed to create cart"); - return; - } + if (!cartId) { alert("Failed to create cart"); return; } const res = await fetch(`${API_URL}/cart/${cartId}/items`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ product_slug: product.slug, variant_sku: selectedVariant.sku, - quantity: quantity, + quantity, }), }); @@ -134,11 +123,8 @@ export default function ProductPage() { const data = await res.json(); alert(data.detail || "Failed to add to cart"); } - } catch { - alert("Failed to add to cart"); - } finally { - setAddingToCart(false); - } + } catch { alert("Failed to add to cart"); } + finally { setAddingToCart(false); } }; if (loading) { @@ -153,16 +139,9 @@ export default function ProductPage() { if (error || !product) { return ( -
-
-

{error || "Product not found"}

- - Back to Products - -
+
+

{error || "Product not found"}

+ Back to Products
); } @@ -171,25 +150,40 @@ export default function ProductPage() {
{/* Breadcrumb */}
- {/* Product Image */} -
- {product.name} + {/* Product Mockup Image */} +
+
+ {`${product.name} +
+ + {/* Mockup type switcher — preview on different products */} +
+ {MOCKUP_TYPES.map((mt) => ( + + ))} +
{/* Product Details */} @@ -201,7 +195,6 @@ export default function ProductPage() {

{product.name}

-

{product.description}

@@ -211,9 +204,7 @@ export default function ProductPage() { {/* Variant Selection */} {product.variants && product.variants.length > 1 && (
- +
{product.variants.map((variant) => (
- {/* Add to Cart Button */} + {/* Add to Cart */} - {/* View Cart Link */} {addedToCart && ( - + View Cart )} {/* Tags */} - {product.tags && product.tags.length > 0 && ( + {product.tags?.length > 0 && (
Tags:
{product.tags.map((tag) => ( - - {tag} - + {tag} ))}
diff --git a/frontend/app/products/page.tsx b/frontend/app/products/page.tsx index 5168e2f..7a8d4e9 100644 --- a/frontend/app/products/page.tsx +++ b/frontend/app/products/page.tsx @@ -13,6 +13,14 @@ interface Product { base_price: number; } +// Map product types to mockup types +function getMockupType(productType: string): string { + if (productType.includes("shirt") || productType.includes("tee") || productType.includes("hoodie")) return "shirt"; + if (productType.includes("sticker")) return "sticker"; + if (productType.includes("print")) return "print"; + return "shirt"; +} + async function getProducts(spaceId: string): Promise { try { const params = new URLSearchParams(); @@ -54,7 +62,7 @@ export default async function ProductsPage() {
{product.name} diff --git a/frontend/public/mockups/print-template.png b/frontend/public/mockups/print-template.png index 1c5dc6b1df3c2a66dea309ce3baa75baeeacdcc5..044e4ffc21dffa25ea2860bedad2ffbcc793a7d7 100644 GIT binary patch literal 4379 zcmeAS@N?(olHy`uVBq!ia0y~yU{(NO4mP03?EkO+NVy4SR2H%bk7iKwYH+F!S{= zFfuS0F|jZ(oZt{(U{FvV6&wwa(Zn#C5k^af(Nb}=Mi{LPM{C0&R2#0j%zR^e?(J7J^W=cZ`AswXXmfY8sZV;}XTQ{RnGa4( R44n)>;OXk;vd$@?2>_nZR7L;* literal 5450 zcmeAS@N?(olHy`uVBq!ia0y~yU{(NO4mP03?ExB6=?~a zUt--m&++_%gJ*6`l$y85j=SUf2NS&Mc4L|L^Y`b09Ong_nbYVM1XOD+5D{hyVkF zLNYTGkeTDaz|hbmtOFES4ALSgjgXs@05nRHm4$)f#0`YpV-{|pfQAACgTpa4gq(T= zP{555sKdw%A!l(AXo(RQ$m)$qa-3p7fj|e4)nI2q{)I<4k z-JgfQ)9o1<9$c;a^>ccxJ2z#58EuEe4x4GVX-yQ#qR zd%)7@l^$A{%u!zaZWA!AJ&=(sosO2U&rJ+0zuCaR05%&X5w8FA^ZNbye_0qFEby1N z|Fiu5WLzpCjs@i#kSm^|<_551LFpgliXzm^26ikcdx2bGiJCFMj>Q@fA42qj;Cvl3 z!yeQA<0q(C8c#qu!%xk{^vdYUb0?#5tNciLY)(^WJFQBf1GN zTC|K7Eu%#Xu)z)KErR+AbZdT(=Doq5_a3g41NMt<<})z7!`ZtVpz5|I{O6aChd0j$ z4$Lr8tD{PhSJqA6zwgh-if_umytMrwN(UG?AoEckH1;z(z5^b18Xb}vkb%E{j2}uQ UI@vDYoXP+Mp00i_>zopr0JW=>lmGw# diff --git a/frontend/public/mockups/shirt-template.png b/frontend/public/mockups/shirt-template.png index 9141640db422db8a834e6546490c55c6d36a9f07..22aa9e527f000d84d1abe494065cd0241c70ba90 100644 GIT binary patch literal 4852 zcmeH~YfzI{8plsUGy%CJWRbuo5O^W6VAasthDabl2rvk4SIdZ?qzUCRf`9~w|8K{k|WzUp8mvo%zi( z&w0*y|8vg&nWOweT%?1YyBz?)Av%ixM*wW-TOVONjEv`OPY1yHb~Hcqi1g3DK7XG3 z`$%^ldh0HRjnAUkhfez8DsKF!ag%x;xf<~i-S(v+&N331ja|8q-B{`3AgcZhzl&-B5&g?fQP4t&_<2&D39ocx2rZ3!M$IdH#&;0uJ>xHGIrSS_h zUrsU3RvFnf-4n>N+sgOn2PXTfYZeSR-F#1|K0!V_U%c2P>X_7RJpBx#K4051#U0zA z&anI4*nRz*YYU@ghicc?EfbqhM8{3TZwwi#`8?I#%LkUHTFZJkW&NZ5m0Ch1!egrv zTgt^`{=ER7z7qce4T1eAHWA_dq;;{htl3L%K|qH8i(5{&4qYlGKMa*h(N?XK>hU-J1s< z?S4>~7s+nfFP6gf*bLSn7f0}PrkU^BkphM`u6TC|hLtOTH8^HVEJZl_&KT0^&x&t< z+?V~3a*@j!>RH>dJ#C%Ji&o`0UnWi!s>~=eeEYsb;O2tgSW+%OMhdc(^}7ki0+^Ui5{gMWou%rvtTkIc@6ZnY(46d~KdIFP;i94>AbGyv za?dZ@i=F!Ua~JNj6JE{PW=5(qV`M=WjlkpQ3-mWXj_xe_2RAoYS>n>uKzU_b$0X8t zlRs^o9(CAgozkPGuLru6^%dX@5ygz?`_|Kzd)R4C+fwO*=1AGISBqyC&)bfeE^*rw zzbo=px5Ti#`KOpXj{#RdHGVljHrm(QyOxyE7StNjeF9om4i$?7Kt~_~Ku350$W#CW zK!7#)eUwn8BL;%1kh)f=X0Wp}#dF0L8=>>7)kfc3EwmV7%K%@_6IaIbg4q_35 zvm7ZiDJU4Q%#)bD08ie5b=&z8Y9$B+5J#dU)Xza61!88jgwAt32xo0WHGx~k2C)`) zlv*(PA>4S+d86JPD|b?ofF5Zn?{KCk#lj4+!f_Vi^(ZMAnV3Wif|h;?CND=z%=TD$ zr85H5AD|j&+|2?N7;xv~eKBESkdpZLL=t^kO8}YSl6^Lq!7C7h8jQjZu<{J3jnp31 zjNk}|VFC00mhz7Y#4Ahz&oqPjCuk@C{$yUQ4jlPB^0U={)G;rj{sa%I<7>>a-<@hEDf6R0atAARFGhZ=mUyp zgV;mRt$w(ixEG}U9AhIZTJbo07e@e~jzkB5Cy+rOaKm5$NU;X3f>;lO^&(iE!Rm_t zyN>Yh*Jjn&*jRRA2L=f98UHa|=sni)-znh#*0V4?J3rvkY|+-l=!iJ}^)TVNe*seO B`M>}G literal 5917 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 diff --git a/frontend/public/mockups/sticker-template.png b/frontend/public/mockups/sticker-template.png index 331961a6d1119f34a21208b87905423480122b91..e5ccd844de8aa5c6e9bc02e3599c1966aaf56edd 100644 GIT binary patch literal 5864 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 q`I%VU==?a~FGGI