diff --git a/packages/experiments-realm/Currency/1.json b/packages/experiments-realm/Currency/1.json new file mode 100644 index 0000000000..4cfab48e48 --- /dev/null +++ b/packages/experiments-realm/Currency/1.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "card", + "attributes": { + "locale": "en-US", + "sign": "$", + "name": "U.S. Dollar", + "symbol": "USD", + "logoURL": "https://i.imgur.com/JIL3hND.png" + }, + "meta": { + "adoptsFrom": { + "module": "../asset", + "name": "Currency" + } + } + } +} diff --git a/packages/experiments-realm/Currency/2.json b/packages/experiments-realm/Currency/2.json new file mode 100644 index 0000000000..7af165898e --- /dev/null +++ b/packages/experiments-realm/Currency/2.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "card", + "attributes": { + "locale": "en-IE", + "sign": "€", + "name": "Euro", + "symbol": "EUR", + "logoURL": "https://i.imgur.com/7pDUUVh.png" + }, + "meta": { + "adoptsFrom": { + "module": "../asset", + "name": "Currency" + } + } + } +} diff --git a/packages/experiments-realm/Currency/3.json b/packages/experiments-realm/Currency/3.json new file mode 100644 index 0000000000..748876ab34 --- /dev/null +++ b/packages/experiments-realm/Currency/3.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "card", + "attributes": { + "locale": "en-IN", + "sign": "₹", + "name": "Indian Rupee", + "symbol": "INR", + "logoURL": "https://i.imgur.com/sSUPndr.png" + }, + "meta": { + "adoptsFrom": { + "module": "../asset", + "name": "Currency" + } + } + } +} diff --git a/packages/experiments-realm/asset.gts b/packages/experiments-realm/asset.gts new file mode 100644 index 0000000000..8ef053ec52 --- /dev/null +++ b/packages/experiments-realm/asset.gts @@ -0,0 +1,173 @@ +import { + contains, + field, + CardDef, + FieldDef, + Component, + relativeTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import CurrencyIcon from '@cardstack/boxel-icons/currency'; +import CircleDotIcon from '@cardstack/boxel-icons/circle-dot'; + +export class Asset extends CardDef { + static displayName = 'Asset'; + @field name = contains(StringField); + @field symbol = contains(StringField); + @field logoURL = contains(StringField); + @field logoHref = contains(StringField, { + computeVia: function (this: Asset) { + if (!this.logoURL) { + return null; + } + return new URL(this.logoURL, this[relativeTo] || this.id).href; + }, + }); + @field title = contains(StringField, { + computeVia: function (this: Asset) { + return this.name; + }, + }); + + static embedded = class Embedded extends Component { + + }; + + static atom = class Atom extends Component { + + }; +} + +class AssetField extends FieldDef { + static displayName = 'Asset'; + @field name = contains(StringField); + @field symbol = contains(StringField); + @field logoURL = contains(StringField); + @field logoHref = contains(StringField, { + computeVia: function (this: Asset) { + if (!this.logoURL) { + return null; + } + return new URL(this.logoURL, this[relativeTo] || this.id).href; + }, + }); + @field title = contains(StringField, { + computeVia: function (this: Asset) { + return this.name; + }, + }); + static embedded = class Embedded extends Component { + + }; +} + +const currencyFormatters = new Map(); + +// For fiat money +export class Currency extends Asset { + static displayName = 'Currency'; + @field sign = contains(StringField); // $, €, £, ¥, ₽, ₿ etc. + @field locale = contains(StringField); // en-US, en-GB, ja-JP, ru-RU, etc. + + get formatter() { + if (!currencyFormatters.has(this.locale)) { + currencyFormatters.set( + this.locale, + new Intl.NumberFormat(this.locale, { + style: 'currency', + currency: this.symbol, + }), + ); + } + return currencyFormatters.get(this.locale)!; + } + + format(amount?: number) { + if (amount === undefined) { + return ''; + } + return this.formatter.format(amount); + } +} + +export class CurrencyField extends AssetField { + static displayName = 'Currency'; + static icon = CurrencyIcon; + @field sign = contains(StringField); // $, €, £, ¥, ₽, ₿ etc. +} + +// For crypto +export class Token extends Asset { + static displayName = 'Token'; + static icon = CircleDotIcon; + @field address = contains(StringField); +} + +export class TokenField extends AssetField { + static displayName = 'Token'; + @field address = contains(StringField); +} diff --git a/packages/experiments-realm/monetary-amount.gts b/packages/experiments-realm/monetary-amount.gts new file mode 100644 index 0000000000..c403b70f27 --- /dev/null +++ b/packages/experiments-realm/monetary-amount.gts @@ -0,0 +1,211 @@ +import NumberField from 'https://cardstack.com/base/number'; +import { + FieldDef, + field, + contains, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import { Component } from 'https://cardstack.com/base/card-api'; +import { Currency } from './asset'; +import { action } from '@ember/object'; +import { BoxelInputGroup } from '@cardstack/boxel-ui/components'; +import { getCards } from '@cardstack/runtime-common'; +import { guidFor } from '@ember/object/internals'; +import GlimmerComponent from '@glimmer/component'; + +// TODO: should this be configurable? +const CURRENCIES_REALM_URL = 'http://localhost:4201/experiments/'; + +interface MonetaryAmountAtomSignature { + Element: HTMLSpanElement; + Args: { + model: MonetaryAmount | Partial | undefined; + }; +} + +export class MonetaryAmountAtom extends GlimmerComponent { + +} + +class Atom extends Component { + +} +class Edit extends Component { + get id() { + return guidFor(this); + } + + // TODO refactor to use component from the @context if you want live search + liveCurrencyQuery = getCards( + { + filter: { + type: { + module: `${CURRENCIES_REALM_URL}asset`, + name: 'Currency', + }, + }, + sort: [ + { + on: { + module: `${CURRENCIES_REALM_URL}asset`, + name: 'Currency', + }, + by: 'name', + }, + ], + }, + [CURRENCIES_REALM_URL], + ); + + @action + setAmount(val: number) { + let newModel = new MonetaryAmount(); + newModel.amount = val; + newModel.currency = this.args.model.currency as Currency; + this.args.set(newModel); + } + + @action + setCurrency(val: Currency) { + let newModel = new MonetaryAmount(); + newModel.amount = this.args.model.amount as number; + newModel.currency = val; + this.args.set(newModel); + } + + +} + +interface MonetaryAmountEmbeddedSignature { + Element: HTMLDivElement; + Args: { + model: MonetaryAmount | Partial | undefined; + }; +} + +export class MonetaryAmountEmbedded extends GlimmerComponent { + +} + +class MonetaryAmountEmbeddedFormat extends Component { + +} + +export class MonetaryAmount extends FieldDef { + static displayName = 'MonetaryAmount'; + + @field amount = contains(NumberField); + @field currency = linksTo(Currency); + + get formattedAmount() { + return this.currency?.format(this.amount); + } + + multiply(multiplier: number) { + let newModel = new MonetaryAmount(); + newModel.amount = (this.amount || 0) * multiplier; + newModel.currency = this.currency; + return newModel; + } + + static edit = Edit; + static atom = Atom; + static embedded = MonetaryAmountEmbeddedFormat; +} diff --git a/packages/experiments-realm/product.gts b/packages/experiments-realm/product.gts new file mode 100644 index 0000000000..f57d345594 --- /dev/null +++ b/packages/experiments-realm/product.gts @@ -0,0 +1,357 @@ +import MarkdownField from 'https://cardstack.com/base/markdown'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import NumberField from 'https://cardstack.com/base/number'; +import { Seller as SellerCard } from './seller'; +import { + MonetaryAmount as MonetaryAmountField, + MonetaryAmountAtom, +} from './monetary-amount'; +import { + CardDef, + field, + linksTo, + contains, + containsMany, + StringField, + FieldsTypeFor, +} from 'https://cardstack.com/base/card-api'; +import { Component } from 'https://cardstack.com/base/card-api'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { cn, eq } from '@cardstack/boxel-ui/helpers'; +import PackageIcon from '@cardstack/boxel-icons/package'; + +export function expectedArrivalDescription( + leadTimeDays: number, + deliveryWindowDays: number, +) { + let min = leadTimeDays; + let max = leadTimeDays + deliveryWindowDays; + // calculate a date range, relative to today + let minDate = new Date(); + minDate.setDate(minDate.getDate() + min); + let maxDate = new Date(); + maxDate.setDate(maxDate.getDate() + max); + let minMonth = minDate.toLocaleString('default', { month: 'short' }); + let maxMonth = maxDate.toLocaleString('default', { month: 'short' }); + let minDay = minDate.getDate(); + let maxDay = maxDate.getDate(); + if (minMonth === maxMonth) { + return `${minMonth} ${minDay}–${maxDay}`; + } else { + return `${minMonth} ${minDay}–${maxMonth} ${maxDay}`; + } +} +interface EmbeddedProductComponentSignature { + Element: HTMLDivElement; + Args: { + model: Partial; + }; +} + +export class EmbeddedProductComponent extends GlimmerComponent { + +} + +interface ProductImagesSignature { + Element: HTMLDivElement; + Args: { + images: string[] | undefined; + activeImage: string | undefined; + onSelectImage: (arg0: string) => void; + }; +} + +export class ProductImages extends GlimmerComponent { + +} + +interface ProductDetailSignature { + Element: HTMLDivElement; + Args: { + model: Partial; + fields: FieldsTypeFor; + }; +} + +export class ProductDetail extends GlimmerComponent { + get leadTimeDays() { + return this.args.model.leadTimeDays || 0; + } + + get deliveryWindowDays() { + return this.args.model.deliveryWindowDays || 0; + } + + +} + +class Isolated extends Component { + @tracked activeImage = this.args.model.images?.[0]; + + @action updateActiveImage(image: string) { + this.activeImage = image; + } + + +} + +export class Product extends CardDef { + static displayName = 'Product'; + static icon = PackageIcon; + + // use title field for product title + + @field images = containsMany(StringField); + @field seller = linksTo(SellerCard); + @field unitPrice = contains(MonetaryAmountField); + @field shippingCost = contains(MonetaryAmountField); + @field leadTimeDays = contains(NumberField); + @field deliveryWindowDays = contains(NumberField); + @field isReturnable = contains(BooleanField); + @field details = contains(MarkdownField); + @field thumbnailURL = contains(StringField, { + computeVia(this: Product) { + return this.images?.[0]; + }, + }); + + static embedded = class Embedded extends Component { + + }; + + static isolated = Isolated; +} diff --git a/packages/experiments-realm/ratings-summary.gts b/packages/experiments-realm/ratings-summary.gts new file mode 100644 index 0000000000..74c176566e --- /dev/null +++ b/packages/experiments-realm/ratings-summary.gts @@ -0,0 +1,230 @@ +import { TemplateOnlyComponent } from '@ember/component/template-only'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import GlimmerComponent from '@glimmer/component'; +import { + contains, + field, + Component, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import NumberField from 'https://cardstack.com/base/number'; + +import { cn, eq } from '@cardstack/boxel-ui/helpers'; +import { Star, StarHalfFill, StarFilled } from '@cardstack/boxel-ui/icons'; + +const numberFormatter = new Intl.NumberFormat('en-US'); + +export function formatNumber(val: number | undefined) { + return val !== undefined ? numberFormatter.format(val) : '0'; +} + +type StarType = 'full' | 'half' | 'empty'; +interface StarIconSignature { + Args: { type: StarType }; + Element: HTMLElement; +} +const StarIcon: TemplateOnlyComponent = ; + +interface StarItem { + rating: number; + type: StarType; +} + +interface StarRatingSignature { + Args: { + value: number | undefined; + isEditable?: boolean; + set: (value: RatingsSummary) => void; + }; + Element: HTMLElement; +} +export class StarRating extends GlimmerComponent { + maxRating = 5; + + get rating() { + return this.args.value ?? 0; + } + + get stars(): StarItem[] { + let starsArray = []; + for (let i = 1; i <= this.maxRating; i++) { + let type: StarType; + if (this.rating >= i) { + type = 'full'; + } else if (this.rating < i && this.rating > i - 1) { + type = 'half'; + } else { + type = 'empty'; + } + starsArray.push({ rating: i, type }); + } + return starsArray; + } + + + + @action changeRating(star: StarItem) { + if (star.type === 'full' && star.rating === this.rating) { + this.args.set( + new RatingsSummary({ average: 0, count: null, isEditable: true }), + ); + return; + } + + /* can only set full values */ + this.args.set( + new RatingsSummary({ + average: star.rating, + count: null, + isEditable: true, + }), + ); + } +} + +export class RatingsSummary extends FieldDef { + static displayName = 'Ratings Summary'; + @field average = contains(NumberField); + @field count = contains(NumberField); + @field isEditable = contains(BooleanField); + + static embedded = class Embedded extends Component { + + }; + + static atom = class Atom extends Component { + + }; +} diff --git a/packages/experiments-realm/seller.gts b/packages/experiments-realm/seller.gts new file mode 100644 index 0000000000..1f936eafc9 --- /dev/null +++ b/packages/experiments-realm/seller.gts @@ -0,0 +1,28 @@ +import { CardDef } from 'https://cardstack.com/base/card-api'; +import { Component } from 'https://cardstack.com/base/card-api'; +import StoreIcon from '@cardstack/boxel-icons/store'; + +export class Seller extends CardDef { + static displayName = 'Seller'; + static icon = StoreIcon; + + static embedded = class Embedded extends Component { + + }; + + /* + static isolated = class Isolated extends Component { + + } + + static atom = class Atom extends Component { + + } + + static edit = class Edit extends Component { + + } + */ +}