Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Unified CodFlow Cloud resource values — single source of truth for the
# seeder scripts, the D1 migration wrapper, the R2 CORS setup, and the
# storefront deploy helper.
#
# Copy this file to .env at the repo root and fill in YOUR values.
# .env is gitignored — never commit real values.
#
# COD_ACCOUNT_ID — Cloudflare account ID (dashboard → account menu)
# COD_DB_NAME — D1 database name (wrangler d1 create <name>)
# COD_R2_BUCKET_NAME — R2 bucket name (wrangler r2 bucket create <name>)
# COD_KV_RATE_LIMIT_ID — KV namespace id for rate limiting
# COD_KV_OAUTH_ID — KV namespace id for the MCP OAuth provider
# COD_SERVER_URL — deployed cod-server origin (storefront fetches this)
# COD_MEDIA_DOMAIN — R2 custom domain, e.g. media.yourdomain.com
#
# Secrets (also read from this file by the setup scripts — never commit them):
#
# STORE_API_KEY — storefront key; sent as X-Store-API-Key
# R2_ACCESS_KEY_ID — R2 API token key id (used by `npm run setup:r2`)
# R2_SECRET_ACCESS_KEY — R2 API token secret (used by `npm run setup:r2`)
#
# Create the R2 token at:
# Cloudflare Dashboard → R2 → Manage R2 API Tokens → Create API Token
# ("Object Read & Write" on the target bucket)

# COD_ACCOUNT_ID=
# COD_DB_NAME=codflow-os-db
# COD_R2_BUCKET_NAME=codflow-images
# COD_KV_RATE_LIMIT_ID=
# COD_KV_OAUTH_ID=
# COD_SERVER_URL=https://api.yourdomain.com
# COD_MEDIA_DOMAIN=media.yourdomain.com
# STORE_API_KEY=
# R2_ACCESS_KEY_ID=
# R2_SECRET_ACCESS_KEY=
2 changes: 1 addition & 1 deletion cod-astro/theme01/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro build && wrangler dev",
"deploy": "astro build && wrangler deploy",
"deploy": "npm run build && node scripts/deploy.mjs",
"cf-typegen": "wrangler types --env-interface CloudflareEnv src/env.d.ts",
"test": "vitest --run",
"test:watch": "vitest",
Expand Down
12 changes: 12 additions & 0 deletions cod-astro/theme01/scripts/deploy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* Deploy the storefront worker with COD_SERVER_URL injected from the unified
* root .env (COD_SERVER_URL) — see cod-server/scripts/cloud-env.mjs.
* STORE_API_KEY is a worker secret, set separately via `wrangler secret put`.
*/

import { execSync } from "node:child_process";
import { getCloudEnv } from "../../../cod-server/scripts/cloud-env.mjs";

const { serverUrl } = getCloudEnv();
execSync(`npx wrangler deploy --var COD_SERVER_URL:${serverUrl}`, { stdio: "inherit" });
13 changes: 13 additions & 0 deletions cod-astro/theme01/src/core/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export const server = {
(v) => (v === "" || v == null ? undefined : v),
z.string().optional()
),
// Upsell offers accepted at checkout — JSON string from hidden form
// input. cod-server re-validates each offer is active and in stock.
upsells: z.preprocess(
(v) => {
if (!v || typeof v !== "string" || v === "[]") return undefined;
try { return JSON.parse(v as string); } catch { return undefined; }
},
z.array(z.object({
productId: z.string().min(1),
quantity: z.coerce.number().int().min(1).max(100),
})).optional()
),
// Per-unit variant selections — JSON string from hidden form input
variantSelections: z.preprocess(
(v) => {
Expand Down Expand Up @@ -94,6 +106,7 @@ export const server = {
pricePerUnit: input.pricePerUnit,
notes: input.notes,
offerId: input.offerId,
upsells: input.upsells,
variantSelections: input.variantSelections,
fbc: input.fbc,
fbp: input.fbp,
Expand Down
6 changes: 5 additions & 1 deletion cod-astro/theme01/src/core/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import {
CategorySchema,
ProductImageSchema,
ProductVariantSchema,
OfferSchema
OfferSchema,
UpsellOfferSchema
} from "./validation";

export type Product = z.infer<typeof ProductSchema>;
export type Category = z.infer<typeof CategorySchema>;
export type ProductImage = z.infer<typeof ProductImageSchema>;
export type ProductVariant = z.infer<typeof ProductVariantSchema>;
export type Offer = z.infer<typeof OfferSchema>;
export type UpsellOffer = z.infer<typeof UpsellOfferSchema>;

export interface StoreConfig {
id: string;
Expand All @@ -34,6 +36,8 @@ export interface StoreConfig {
announcementBar: string | null;
reviewsEnabled: boolean;
otpEnabled: boolean;
upsellInlineEnabled: boolean;
upsellModalEnabled: boolean;
status: "active" | "inactive";
pixelId?: string | null;
}
Expand Down
20 changes: 20 additions & 0 deletions cod-astro/theme01/src/core/api/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ export const OfferSchema = z.object({
rewardVariantLabel: z.string().nullable(),
});

/**
* Upsell Offer Schema — an extra product offered at checkout alongside the
* product being bought. `price` is already the effective unit price.
*/
export const UpsellOfferSchema = z.object({
id: z.string(),
productId: z.string(),
upsellProductId: z.string(),
name: z.string(),
description: z.string().nullable(),
price: z.number(),
compareAtPrice: z.number().nullable(),
hasVariants: z.boolean(),
variantId: z.string().nullable(),
variantLabel: z.string().nullable(),
primaryImageSrc: z.string().nullable(),
position: z.number(),
});

/**
* Product Schema for Content Collections
*/
Expand Down Expand Up @@ -80,6 +99,7 @@ export const ProductSchema = z.object({
reviewCount: z.number(),
}).nullable().optional(),
offers: z.array(OfferSchema),
upsells: z.array(UpsellOfferSchema).optional().default([]),
});

/**
Expand Down
14 changes: 12 additions & 2 deletions cod-astro/theme01/src/theme/components/order/OrderForm.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
import { actions } from "astro:actions";
import type { Product, ProductVariant, Offer } from "@/core/api/types";
import type { Product, ProductVariant, Offer, UpsellOffer } from "@/core/api/types";
import type { StoreFrontContent } from "@/theme/config/content";
import { Icon } from "astro-icon/components";

Expand All @@ -9,6 +9,8 @@ import OfferTiers from "./OfferTiers.astro";
import CustomerFields from "./CustomerFields.astro";
import OrderSummary from "./OrderSummary.astro";
import OtpStep from "./OtpStep.astro";
import UpsellOffers from "./UpsellOffers.astro";
import UpsellModal from "./UpsellModal.astro";

interface Props {
product: Product;
Expand All @@ -23,9 +25,11 @@ interface Props {
defaultVariantId: string | null;
isOutOfStock?: boolean;
otpEnabled?: boolean;
upsells?: UpsellOffer[];
modalUpsells?: UpsellOffer[];
}

const { product, content, fieldErrors, serverError, basePrice, defaultVariant, cur, isRTL, offers, defaultVariantId, isOutOfStock = false, otpEnabled = false } = Astro.props;
const { product, content, fieldErrors, serverError, basePrice, defaultVariant, cur, isRTL, offers, defaultVariantId, isOutOfStock = false, otpEnabled = false, upsells = [], modalUpsells = [] } = Astro.props;

const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ?? null;
---
Expand Down Expand Up @@ -69,6 +73,7 @@ const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ??
<input type="hidden" name="variantLabel" value="" id="variant-label-input" />
<input type="hidden" name="offerId" value="" id="offer-id-input" />
<input type="hidden" name="variantSelections" value="[]" id="variant-selections-input" />
<input type="hidden" name="upsells" value="[]" id="upsells-input" />
{/* WhatsApp OTP proof — set by the OTP step script after verification */}
<input type="hidden" name="otpToken" id="otp-token-input" value="" />

Expand All @@ -86,6 +91,8 @@ const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ??
defaultVariantId={defaultVariantId}
/>

<UpsellOffers upsells={upsells} cur={cur} content={content} />

<CustomerFields
content={content}
fieldErrors={fieldErrors}
Expand All @@ -99,6 +106,7 @@ const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ??
basePrice={basePrice}
cur={cur}
offers={offers}
upsells={[...upsells, ...modalUpsells.filter((offer) => !upsells.some((shown) => shown.upsellProductId === offer.upsellProductId))]}
content={content}
/>

Expand All @@ -119,4 +127,6 @@ const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ??
{content.formConfirmNote}
</p>
</form>

<UpsellModal upsells={modalUpsells} cur={cur} content={content} />
</div>
17 changes: 15 additions & 2 deletions cod-astro/theme01/src/theme/components/order/OrderSummary.astro
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
---
import type { Product, Offer } from "@/core/api/types";
import type { Product, Offer, UpsellOffer } from "@/core/api/types";
import type { StoreFrontContent } from "@/theme/config/content";

interface Props {
product: Product;
basePrice: number;
cur: string;
offers: Offer[];
upsells?: UpsellOffer[];
content: StoreFrontContent;
}

const { product, basePrice, cur, offers, content } = Astro.props;
const { product, basePrice, cur, offers, upsells = [], content } = Astro.props;

function fmt(n: number) {
return n.toLocaleString("ar-DZ");
Expand Down Expand Up @@ -60,6 +61,18 @@ function fmt(n: number) {
);
})}

<!-- Selected upsell rows (revealed by the upsell checkboxes) -->
{upsells.map((offer) => (
<div
id={`upsell-row-${offer.upsellProductId}`}
class="hidden items-center justify-between gap-2 text-[0.8125rem] sm:text-[0.875rem] font-bold"
style="color: var(--clr-text)"
>
<span class="truncate flex-1">{offer.name}</span>
<span class="shrink-0 whitespace-nowrap">{fmt(offer.price)} {cur}</span>
</div>
))}

<!-- Total line: Better mobile sizing -->
<div class="pt-3 sm:pt-4 mt-2 border-t-2 border-dashed flex items-center justify-between gap-2" style="border-color: var(--clr-border)">
<span class="font-black text-[0.9375rem] sm:text-[1rem]" style="color: var(--clr-text)">{content.totalLabel}</span>
Expand Down
120 changes: 120 additions & 0 deletions cod-astro/theme01/src/theme/components/order/UpsellModal.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
import type { UpsellOffer } from "@/core/api/types";
import type { StoreFrontContent } from "@/theme/config/content";
import { Icon } from "astro-icon/components";

interface Props {
upsells: UpsellOffer[];
cur: string;
content: StoreFrontContent;
}

const { upsells, cur, content } = Astro.props;

function fmt(n: number) {
return n.toLocaleString("ar-DZ");
}
---

{upsells.length > 0 && (
<dialog
id="upsell-modal"
class="w-[min(30rem,calc(100vw-2rem))] p-0 rounded-[1.5rem] border-2 backdrop:bg-black/50"
style="background: var(--clr-surface); border-color: var(--clr-border); color: var(--clr-text)"
>
<div class="p-6 space-y-5">
<div class="flex items-start gap-3">
<span
class="w-10 h-10 rounded-2xl flex items-center justify-center shrink-0"
style="background: var(--clr-surface-alt); color: var(--clr-primary)"
>
<Icon name="heroicons:gift" class="w-5 h-5" />
</span>
<div>
<h2 class="font-black text-[1rem] leading-tight" style="color: var(--clr-text)">
{content.upsellModalTitle}
</h2>
<p class="text-[0.75rem] font-bold mt-0.5" style="color: var(--clr-text-2)">
{content.upsellModalSubtitle}
</p>
</div>
</div>

<ul class="space-y-2 max-h-[50vh] overflow-y-auto">
{upsells.map((offer) => (
<li>
<label
class="flex items-center gap-3 p-3 rounded-[1.25rem] border-2 cursor-pointer transition-colors"
style="background: var(--clr-surface); border-color: var(--clr-border)"
data-upsell-card={offer.upsellProductId}
>
<input
type="checkbox"
class="upsell-toggle w-5 h-5 shrink-0 accent-[var(--clr-primary)] cursor-pointer"
data-product-id={offer.upsellProductId}
data-price={offer.price}
data-name={offer.name}
aria-label={content.ariaUpsellOffer.replace("{name}", offer.name)}
/>

{offer.primaryImageSrc ? (
<img
src={offer.primaryImageSrc}
alt=""
loading="lazy"
width="48"
height="48"
class="w-12 h-12 rounded-xl object-cover shrink-0"
/>
) : (
<span class="w-12 h-12 rounded-xl shrink-0" style="background: var(--clr-surface-alt)"></span>
)}

<div class="min-w-0 flex-1">
<p class="font-bold text-[0.875rem] leading-tight line-clamp-2" style="color: var(--clr-text)">
{offer.name}
{offer.variantLabel && (
<span class="font-bold" style="color: var(--clr-text-2)"> ({offer.variantLabel})</span>
)}
</p>
<p class="mt-1 flex items-center gap-2 whitespace-nowrap">
<span class="font-black text-[0.9375rem]" style="color: var(--clr-primary)">
{fmt(offer.price)} {cur}
</span>
{offer.compareAtPrice !== null && offer.compareAtPrice > offer.price && (
<span class="font-bold text-[0.8125rem] line-through" style="color: var(--clr-text-2)">
{fmt(offer.compareAtPrice)} {cur}
</span>
)}
</p>
</div>

<span
class="upsell-state shrink-0 text-[0.75rem] font-black uppercase tracking-widest"
style="color: var(--clr-text-2)"
data-add={content.upsellAdd}
data-added={content.upsellAdded}
>
{content.upsellAdd}
</span>
</label>
</li>
))}
</ul>

<div class="space-y-2">
<button type="button" id="upsell-modal-continue" class="btn-primary !h-[3.25rem] w-full">
{content.upsellModalContinue}
</button>
<button
type="button"
id="upsell-modal-skip"
class="w-full text-[0.8125rem] font-bold underline underline-offset-4 py-2"
style="color: var(--clr-text-2)"
>
{content.upsellModalSkip}
</button>
</div>
</div>
</dialog>
)}
Loading